diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 50ecaa3..43f0ce7 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -402,6 +402,106 @@ fn rebase_css_urls(css: &str, base: &url::Url) -> String { out } +/// Extract network-backed `url(...)` assets while respecting CSS comments and +/// strings. Linked sheets have already been rebased before materialization; +/// inline declarations are resolved against the document base here. +fn css_resource_urls(css: &str, base: &url::Url) -> Vec { + let mut urls = Vec::new(); + let mut index = 0usize; + while index < css.len() { + let rest = &css[index..]; + if rest.starts_with("/*") { + if let Some(end) = rest[2..].find("*/") { + index += end + 4; + } else { + break; + } + continue; + } + let Some(first) = rest.chars().next() else { break }; + if first == '"' || first == '\'' { + let quote = first; + let mut escaped = false; + let mut length = quote.len_utf8(); + for ch in rest[quote.len_utf8()..].chars() { + length += ch.len_utf8(); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == quote { + break; + } + } + index += length; + continue; + } + if !rest.get(..4).is_some_and(|prefix| prefix.eq_ignore_ascii_case("url(")) { + index += first.len_utf8(); + continue; + } + let mut quote = None; + let mut escaped = false; + let mut end = None; + for (offset, ch) in rest[4..].char_indices() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + match quote { + Some(open) if ch == open => quote = None, + Some(_) => {} + None if ch == '"' || ch == '\'' => quote = Some(ch), + None if ch == ')' => { + end = Some(4 + offset); + break; + } + None => {} + } + } + let Some(end) = end else { break }; + let raw = rest[4..end].trim(); + let value = if raw.len() >= 2 + && ((raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\''))) + { + &raw[1..raw.len() - 1] + } else { + raw + }; + if !value.is_empty() + && !value.starts_with('#') + && !value.starts_with("data:") + && !value.contains("var(") + { + if let Ok(mut url) = base.join(value) { + url.set_fragment(None); + if matches!(url.scheme(), "http" | "https") { + urls.push(url.to_string()); + } + } + } + index += end + 1; + } + urls +} + +fn render_resource_type(url: &url::Url) -> ResourceType { + let path = url.path().to_ascii_lowercase(); + if [".woff", ".woff2", ".ttf", ".otf", ".eot"] + .iter() + .any(|extension| path.ends_with(extension)) + { + ResourceType::Font + } else { + ResourceType::Image + } +} + /// Pull leading `@import` rules out of a stylesheet. Returns each import target /// URL (skipping print-only and `prefers-color-scheme: dark` conditional /// imports, which must not apply in our light desktop context) plus the CSS @@ -955,6 +1055,11 @@ impl Page { } async fn execute_scripts(&mut self) { + self.execute_scripts_with_module_budget(None).await; + } + + async fn execute_scripts_with_module_budget(&mut self, module_budget_override: Option) { + let scripts_started = std::time::Instant::now(); tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); // Soft deadline on the entire script-execution phase. Heavy SPAs // (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial @@ -1280,13 +1385,19 @@ impl Page { }) }) .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); + let short_ms: u64 = module_budget_override.unwrap_or_else(|| { + 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 } + if module_budget_override.is_some() || body_nodes > 50 { + short_ms + } else { + script_deadline_ms + } }; enum ScheduledScript { @@ -1294,9 +1405,22 @@ impl Page { Module { prepared: obscura_js::runtime::PreparedModule, url: Option, + deadline: tokio::time::Instant, + started: std::time::Instant, }, } + let remaining_budget_ms = |deadline: tokio::time::Instant| -> Option { + let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?; + if remaining.is_zero() { + return None; + } + let millis = remaining.as_millis().saturating_add(u128::from( + remaining.subsec_nanos() % 1_000_000 != 0, + )); + Some(millis.min(u128::from(u64::MAX)) as u64) + }; + let execute_classic = | page: &mut Self, script: &ScriptInfo, @@ -1375,6 +1499,19 @@ impl Page { } } ScriptKind::Module => { + // This is one load+evaluation budget, not one allowance per + // phase. Cap it by the page-wide script deadline as well. + let module_started = std::time::Instant::now(); + let module_deadline = std::cmp::min( + script_deadline, + tokio::time::Instant::now() + + tokio::time::Duration::from_millis(module_budget_ms), + ); + let Some(prepare_budget_ms) = remaining_budget_ms(module_deadline) else { + tracing::warn!("ES module budget exhausted before graph preparation"); + continue; + }; + let prepare_started = std::time::Instant::now(); let (prepared, module_url) = if let Some(src) = &script.src { let full_url = if src.starts_with("http://") || src.starts_with("https://") @@ -1390,9 +1527,17 @@ impl Page { }; tracing::info!("Preparing ES module graph: {}", full_url); let result = match &mut self.js { - Some(js) => js.prepare_module(&full_url, module_budget_ms).await, + Some(js) => js.prepare_module(&full_url, prepare_budget_ms).await, None => continue, }; + tracing::debug!( + phase = "module-graph", + module = %full_url, + elapsed_ms = prepare_started.elapsed().as_millis(), + budget_ms = prepare_budget_ms, + success = result.is_ok(), + "ES module phase complete", + ); match result { Ok(prepared) => (prepared, Some(full_url)), Err(error) => { @@ -1403,10 +1548,18 @@ impl Page { } else { let result = match &mut self.js { Some(js) => js.prepare_inline_module( - &script.inline, &script.base_url, module_budget_ms, + &script.inline, &script.base_url, prepare_budget_ms, ).await, None => continue, }; + tracing::debug!( + phase = "module-graph", + module = "", + elapsed_ms = prepare_started.elapsed().as_millis(), + budget_ms = prepare_budget_ms, + success = result.is_ok(), + "ES module phase complete", + ); match result { Ok(prepared) => (prepared, None), Err(error) => { @@ -1418,15 +1571,42 @@ impl Page { let scheduled = ScheduledScript::Module { prepared, url: module_url, + deadline: module_deadline, + started: module_started, }; if script.is_async { - let ScheduledScript::Module { prepared, url } = scheduled else { + let ScheduledScript::Module { + prepared, + url, + deadline, + started, + } = scheduled else { unreachable!(); }; + let Some(evaluation_budget_ms) = remaining_budget_ms(deadline) else { + tracing::warn!( + module = url.as_deref().unwrap_or(""), + elapsed_ms = started.elapsed().as_millis(), + "ES module exhausted its shared budget before evaluation", + ); + continue; + }; + let evaluation_started = std::time::Instant::now(); let result = match &mut self.js { - Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await, + Some(js) => js + .evaluate_prepared_module(prepared, evaluation_budget_ms) + .await, None => continue, }; + tracing::debug!( + phase = "module-evaluation", + module = url.as_deref().unwrap_or(""), + elapsed_ms = evaluation_started.elapsed().as_millis(), + total_elapsed_ms = started.elapsed().as_millis(), + budget_ms = evaluation_budget_ms, + success = result.is_ok(), + "ES module phase complete", + ); if let Err(error) = result { tracing::warn!("ES module evaluation error: {}", error); } else if let Some(url) = url { @@ -1454,11 +1634,36 @@ impl Page { let fetched_script = fetched.remove(&index); execute_classic(self, script, fetched_script); } - ScheduledScript::Module { prepared, url } => { + ScheduledScript::Module { + prepared, + url, + deadline, + started, + } => { + let Some(evaluation_budget_ms) = remaining_budget_ms(deadline) else { + tracing::warn!( + module = url.as_deref().unwrap_or(""), + elapsed_ms = started.elapsed().as_millis(), + "ES module exhausted its shared budget before post-parse evaluation", + ); + continue; + }; + let evaluation_started = std::time::Instant::now(); let result = match &mut self.js { - Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await, + Some(js) => js + .evaluate_prepared_module(prepared, evaluation_budget_ms) + .await, None => continue, }; + tracing::debug!( + phase = "module-evaluation", + module = url.as_deref().unwrap_or(""), + elapsed_ms = evaluation_started.elapsed().as_millis(), + total_elapsed_ms = started.elapsed().as_millis(), + budget_ms = evaluation_budget_ms, + success = result.is_ok(), + "ES module phase complete", + ); if let Err(error) = result { tracing::warn!("ES module evaluation error: {}", error); } else if let Some(url) = url { @@ -1548,6 +1753,12 @@ impl Page { js.disarm_watchdog(token); } } + tracing::debug!( + phase = "script-execution-total", + elapsed_ms = scripts_started.elapsed().as_millis(), + budget_ms = script_deadline_ms, + "script execution phase complete", + ); } pub async fn navigate(&mut self, url_str: &str) -> Result<(), PageError> { @@ -1986,6 +2197,157 @@ impl Page { self.dom.as_ref().map(f) } + /// Concurrently seed the synchronous renderer cache through the owning + /// page transport. This removes serial image/font HTTP from the first + /// screenshot while retaining cookies, proxy policy, interception, CORS, + /// response limits, and connection pooling. + #[cfg(feature = "render")] + pub async fn prepare_screenshot_resources(&mut self, max_ms: u64) -> usize { + let started = std::time::Instant::now(); + if max_ms == 0 || self.js.is_none() { + return 0; + } + let Some(document_url) = self.url.clone() else { + return 0; + }; + let base_url = self.resolve_base_url().unwrap_or_else(|| document_url.clone()); + let mut candidates = std::collections::BTreeMap::new(); + + if let Some(js) = &self.js { + for raw in js.pending_render_image_urls() { + if let Ok(mut url) = url::Url::parse(&raw) { + url.set_fragment(None); + candidates.insert(url.to_string(), ResourceType::Image); + } + } + let css_sources = js.with_dom(|dom| { + let mut sources = Vec::new(); + for id in dom.descendants(dom.document()) { + let Some(node) = dom.get_node(id) else { continue }; + if node + .as_element() + .is_some_and(|element| element.local.as_ref() == "style") + { + sources.push(dom.text_content(id)); + } + if let Some(style) = node.get_attribute("style") { + sources.push(style.to_string()); + } + if node + .as_element() + .is_some_and(|element| element.local.as_ref() == "use") + { + if let Some(href) = node + .get_attribute("href") + .or_else(|| node.get_attribute("xlink:href")) + { + sources.push(format!("url({href})")); + } + } + } + sources + }).unwrap_or_default(); + for css in css_sources { + for raw in css_resource_urls(&css, &base_url) { + if let Ok(url) = url::Url::parse(&raw) { + candidates.insert(raw, render_resource_type(&url)); + } + } + } + candidates.retain(|url, _| !js.render_resource_is_known(url)); + } + + candidates.retain(|url, _| { + subresource_allowed(Some(&document_url), url) && !self.should_block_url(url) + }); + if candidates.len() > 128 { + candidates = candidates.into_iter().take(128).collect(); + } + if candidates.is_empty() { + return 0; + } + + let requested: Vec<(String, ResourceType)> = candidates.into_iter().collect(); + let client = self.http_client.clone(); + #[cfg(feature = "stealth")] + let stealth_client = self.stealth_client.clone(); + let callbacks = self.callbacks.clone(); + let initiator = document_url.clone(); + use futures::StreamExt as _; + let requests = futures::stream::iter(requested.clone().into_iter().map(|(raw, kind)| { + let client = client.clone(); + #[cfg(feature = "stealth")] + let stealth_client = stealth_client.clone(); + let callbacks = callbacks.clone(); + let initiator = initiator.clone(); + async move { + let parsed = url::Url::parse(&raw).expect("validated render resource URL"); + let request = ResourceRequest::subresource(kind, &initiator); + #[cfg(feature = "stealth")] + let result = if let Some(stealth_client) = stealth_client { + stealth_client + .fetch_resource_with_callbacks(&parsed, request, Some(&callbacks)) + .await + } else { + client + .fetch_resource_with_callbacks(&parsed, request, Some(&callbacks)) + .await + }; + #[cfg(not(feature = "stealth"))] + let result = client + .fetch_resource_with_callbacks(&parsed, request, Some(&callbacks)) + .await; + (raw, kind, result) + } + })).buffer_unordered(16); + futures::pin_mut!(requests); + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_millis(max_ms); + let mut outcomes = std::collections::HashMap::new(); + loop { + match tokio::time::timeout_at(deadline, requests.next()).await { + Ok(Some((raw, kind, result))) => { + outcomes.insert(raw, (kind, result)); + } + Ok(None) | Err(_) => break, + } + } + drop(requests); + + let mut loaded = 0usize; + for (raw, kind) in requested { + let bytes = match outcomes.remove(&raw) { + Some((_, Ok(response))) => { + self.record_network_event_with_body( + response.url.as_str(), + "GET", + match kind { ResourceType::Font => "Font", _ => "Image" }, + response.status, + &response.headers, + &response.body, + true, + ); + if (200..300).contains(&response.status) { + loaded += 1; + Some(response.body) + } else { + None + } + } + _ => None, + }; + if let Some(js) = &mut self.js { + js.seed_render_resource(raw, bytes); + } + } + tracing::debug!( + loaded, + elapsed_ms = started.elapsed().as_millis(), + "prepared screenshot resources through page transport" + ); + loaded + } + /// Rasterize the current DOM to PNG bytes at `viewport` (CSS pixels), when /// the render feature is compiled in. None if the page has no DOM or the /// viewport is zero-sized. @@ -2542,13 +2904,33 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool { #[cfg(test)] mod tests { use super::{ - linked_stylesheet_requests, materialize_linked_stylesheet_script, parse_import_url, - navigation_referrer, rebase_css_urls, split_css_imports, + css_resource_urls, linked_stylesheet_requests, materialize_linked_stylesheet_script, + parse_import_url, navigation_referrer, rebase_css_urls, split_css_imports, script_response_is_executable, truncate_on_char_boundary, url_matches_cdp_pattern, }; use base64::Engine as _; use obscura_dom::parse_html; + #[test] + fn css_resource_discovery_ignores_strings_comments_data_and_fragments() { + let base = url::Url::parse("https://example.test/css/app/main.css").unwrap(); + let css = r#" + /* url(ignored.png) */ + .copy::before { content: "url(also-ignored.png)"; } + .hero { background: url('../img/hero.png'); } + .icon { mask: URL("https://cdn.test/icon.svg#shape"); } + .inline { background: url(data:image/svg+xml,); } + .local { mask: url(#local); } + "#; + assert_eq!( + css_resource_urls(css, &base), + vec![ + "https://example.test/css/img/hero.png".to_string(), + "https://cdn.test/icon.svg".to_string(), + ] + ); + } + fn spawn_stylesheet_graph_server( expected_requests: usize, ) -> (String, std::sync::mpsc::Receiver) { @@ -3021,6 +3403,73 @@ mod tests { page } + #[tokio::test(flavor = "current_thread")] + async fn module_graph_and_evaluation_share_one_absolute_budget() { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (request_tx, request_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 2048]; + let length = stream.read(&mut request).unwrap(); + let path = String::from_utf8_lossy(&request[..length]) + .lines() + .next() + .and_then(|line| line.split_ascii_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + request_tx.send(path).unwrap(); + + // Spend part of the module's allowance loading its graph. The + // synchronous top-level work then fits in a freshly reset budget, + // but cannot fit in the one absolute load+evaluation budget. + std::thread::sleep(std::time::Duration::from_millis(100)); + let body = "export const delayed = true;"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + + let base = format!("http://{address}"); + let mut page = import_map_test_page( + "shared-module-budget", + &base, + r#""#, + ); + page.execute_scripts_with_module_budget(Some(350)).await; + + assert_eq!( + request_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "/app/delayed.js", + ); + let state = page + .js + .as_mut() + .unwrap() + .evaluate( + "[globalThis.__shared_deadline_started === true, \ + globalThis.__shared_deadline_completed === true]", + ) + .unwrap(); + assert_eq!( + state, + serde_json::json!([true, false]), + "evaluation must be terminated at the remaining shared deadline", + ); + } + #[tokio::test(flavor = "current_thread")] async fn parser_import_map_before_first_module_controls_resolution() { let (base, requests) = spawn_parser_import_map_server(1); @@ -3168,6 +3617,70 @@ mod tests { assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js"); } + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn page_transport_prefetches_once_and_capture_reuses_the_bytes() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let (seen_tx, seen_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0u8; 2048]; + let read = stream.read(&mut request).unwrap_or(0); + let first = String::from_utf8_lossy(&request[..read]) + .lines().next().unwrap_or_default().to_string(); + seen_tx.send(first).unwrap(); + let body = br##""##; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(body).unwrap(); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + + let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( + "render-prefetch".to_string(), None, false, None, None, true, + )); + let mut page = super::Page::new("render-prefetch".to_string(), context); + page.set_viewport((100.0, 80.0)); + let page_url = format!("http://{address}/page"); + let asset_url = format!("http://{address}/asset.svg"); + let dom = parse_html(&format!( + r#""# + )); + let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new(); + runtime.set_dom(dom); + runtime.set_url(&page_url); + runtime.set_viewport(100.0, 80.0); + runtime.run_page_init(); + page.js = Some(runtime); + page.url = Some(url::Url::parse(&page_url).unwrap()); + + assert_eq!(page.prepare_screenshot_resources(1_000).await, 1); + page.screenshot(page.viewport).expect("prefetched capture"); + assert!(seen_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap() + .starts_with("GET /asset.svg ")); + assert!( + seen_rx.recv_timeout(std::time::Duration::from_millis(200)).is_err(), + "capture must not open a second synchronous renderer request" + ); + } + #[cfg(feature = "render")] #[test] fn page_screenshot_uses_the_live_window_scroll_offset() { diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 4a0d186..1ff4864 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -781,6 +781,11 @@ async fn run_fetch( if let Some(ref path) = screenshot { #[cfg(feature = "render")] { + let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(3_000); + let _ = page.prepare_screenshot_resources(resource_deadline_ms).await; // Default CSS-pixel viewport, matching the engine's innerWidth/Height. // OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to // capture below-the-fold content in one shot). diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index 1a99384..9836525 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -12,4 +12,7 @@ pub use v8_flags::set_v8_flags; // Screenshot rasterization (PNG bytes) from the render layer. Available when the // render feature (which enables obscura-render/paint) is compiled in. #[cfg(feature = "render")] -pub use obscura_render::{screenshot_png, screenshot_png_scrolled}; +pub use obscura_render::{ + screenshot_png, screenshot_png_scrolled, CaptureError, CaptureRegion, + MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, +}; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 2afc3cf..92a43d6 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -64,6 +64,19 @@ pub struct PreparedModule { description: String, } +fn remaining_deadline_ms(deadline: tokio::time::Instant) -> Option { + let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?; + if remaining.is_zero() { + return None; + } + // Round up so a positive sub-millisecond remainder still gets one bounded + // event-loop turn. The watchdog supplies the hard wall-clock boundary. + let millis = remaining.as_millis().saturating_add( + u128::from(remaining.subsec_nanos() % 1_000_000 != 0), + ); + Some(millis.min(u128::from(u64::MAX)) as u64) +} + /// Handle to an armed V8 execution watchdog (see [`ObscuraJsRuntime::arm_watchdog`]). /// Holds the cancel channel and the watchdog thread; pass it back to /// `disarm_watchdog` to stop the watchdog and learn whether it fired. @@ -406,6 +419,91 @@ impl ObscuraJsRuntime { ) } + /// Capture a document-space rectangle without changing the live viewport, + /// root scroll, element scroll offsets, or retained layout. The current + /// resolved scroll snapshot supplies fixed/sticky and nested-scroll state. + #[cfg(feature = "render")] + pub fn screenshot_prepared_region( + &self, + region: obscura_render::CaptureRegion, + ) -> Result, obscura_render::CaptureError> { + let mut state = self.state.borrow_mut(); + ensure_resolved_scroll(&mut state).ok_or(obscura_render::CaptureError::PaintFailed)?; + let ObscuraState { + dom, + prepared_render, + render_resources, + resolved_scroll, + .. + } = &mut *state; + let (_, scroll) = resolved_scroll + .as_ref() + .ok_or(obscura_render::CaptureError::PaintFailed)?; + obscura_render::screenshot_prepared_region_with_scroll( + dom.as_ref() + .ok_or(obscura_render::CaptureError::PaintFailed)?, + prepared_render + .as_mut() + .ok_or(obscura_render::CaptureError::PaintFailed)?, + render_resources, + scroll, + region, + ) + } + + /// Return the exact responsive candidates selected for live `` + /// elements without loading them. The browser layer can then fetch them + /// concurrently through the page-owned transport before synchronous layout + /// or paint observes the cache. + #[cfg(feature = "render")] + pub fn pending_render_image_urls(&self) -> Vec { + let state = self.state.borrow(); + let base_url = document_base_url(&state); + let Some(dom) = state.dom.as_ref() else { + return Vec::new(); + }; + let mut urls = Vec::new(); + for id in dom.descendants(dom.document()) { + let Some(node) = dom.get_node(id) else { continue }; + if node + .as_element() + .is_none_or(|element| element.local.as_ref() != "img") + { + continue; + } + let Some((url, _, known, _)) = state.render_resources + .cached_image_element_metadata(dom, id, state.viewport, base_url.as_deref()) + else { + continue; + }; + if !known && !url.starts_with("data:") { + urls.push(url); + } + } + urls.sort(); + urls.dedup(); + urls + } + + /// Insert one page-transport resource outcome into the retained renderer + /// cache. A new successful byte body can change intrinsic geometry, so any + /// previously prepared layout is invalidated. + #[cfg(feature = "render")] + pub fn seed_render_resource(&mut self, url: String, bytes: Option>) { + let mut state = self.state.borrow_mut(); + match bytes { + Some(bytes) => state.render_resources.seed(url, bytes), + None => state.render_resources.seed_missing(url), + } + state.prepared_render = None; + state.resolved_scroll = None; + } + + #[cfg(feature = "render")] + pub fn render_resource_is_known(&self, url: &str) -> bool { + self.state.borrow().render_resources.has_live_outcome(url) + } + /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.) /// have been set. Must be called once per page setup, after all set_* methods. pub fn run_page_init(&mut self) { @@ -786,8 +884,13 @@ impl ObscuraJsRuntime { self.object_store.clear(); } pub async fn load_module(&mut self, url: &str, budget_ms: u64) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_millis(budget_ms); let prepared = self.prepare_module(url, budget_ms).await?; - self.evaluate_prepared_module(prepared, budget_ms).await + let remaining_ms = remaining_deadline_ms(deadline).ok_or_else(|| { + format!("Module {} exhausted its {}ms load+evaluation budget", url, budget_ms) + })?; + self.evaluate_prepared_module(prepared, remaining_ms).await } pub async fn prepare_module(&mut self, url: &str, budget_ms: u64) -> Result { @@ -869,10 +972,15 @@ impl ObscuraJsRuntime { } pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_millis(budget_ms); let prepared = self .prepare_inline_module(code, base_url, budget_ms) .await?; - self.evaluate_prepared_module(prepared, budget_ms).await + let remaining_ms = remaining_deadline_ms(deadline).ok_or_else(|| { + format!("Inline module exhausted its {}ms load+evaluation budget", budget_ms) + })?; + self.evaluate_prepared_module(prepared, remaining_ms).await } pub async fn prepare_inline_module( @@ -923,8 +1031,27 @@ impl ObscuraJsRuntime { prepared: PreparedModule, budget_ms: u64, ) -> Result<(), String> { - self.drive_module_eval(prepared.module_id, budget_ms, &prepared.description) - .await + let PreparedModule { + module_id, + description, + } = prepared; + // Tokio timeouts cannot run while synchronous top-level module work + // pins the runtime thread in V8. Pair the async timeout with a hard V8 + // watchdog so this budget is a real wall-clock ceiling for both forms + // of evaluation. + let watchdog = self.arm_watchdog(std::time::Duration::from_millis(budget_ms)); + let result = self + .drive_module_eval(module_id, budget_ms, &description) + .await; + let watchdog_fired = self.disarm_watchdog(watchdog); + if watchdog_fired { + Err(format!( + "{} evaluation timed out after {}ms", + description, budget_ms + )) + } else { + result + } } fn execute_classic_script(&mut self, name: &str, source: &str) -> Result<(), String> { @@ -4803,6 +4930,109 @@ mod tests { ); } + #[cfg(feature = "render")] + #[test] + fn document_region_capture_preserves_live_runtime_state_and_resource_cache() { + let dom = parse_html( + r#" +
+ +
+
+ "#, + ); + let loads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let loader_loads = loads.clone(); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_url("http://example.test/page"); + rt.set_viewport(80.0, 60.0); + rt.state.borrow_mut().render_resources = + obscura_render::RenderResourceCache::with_loader(move |url: &str| { + assert_eq!(url, "http://example.test/marker.svg"); + loader_loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some( + br##" + + "## + .to_vec(), + ) + }); + rt.run_page_init(); + assert_eq!( + rt.evaluate("(function(){ window.scrollTo(0, 50); return window.scrollY; })()") + .expect("live scroll") + .as_f64(), + Some(50.0) + ); + let live_before = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("live screenshot"); + let (prepared_address, viewport, scroll_offset, scroll_generation, resolved_root, full_height) = { + let state = rt.state.borrow(); + let prepared = state.prepared_render.as_ref().expect("prepared render"); + ( + prepared as *const obscura_render::PreparedRender as usize, + state.viewport, + state.scroll_offset, + state.scroll_generation, + state + .resolved_scroll + .as_ref() + .expect("resolved scroll") + .1 + .root_offset(), + prepared.content_size().1, + ) + }; + assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1); + + let region_png = rt + .screenshot_prepared_region(obscura_render::CaptureRegion::new( + 0.0, 115.0, 80.0, 40.0, 1.5, + )) + .expect("offscreen scaled region"); + let full_png = rt + .screenshot_prepared_region(obscura_render::CaptureRegion::new( + 0.0, 0.0, 80.0, full_height, 1.0, + )) + .expect("full-content region"); + let png_size = |bytes: &[u8]| { + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + ( + u32::from_be_bytes(bytes[16..20].try_into().expect("PNG width")), + u32::from_be_bytes(bytes[20..24].try_into().expect("PNG height")), + ) + }; + assert_eq!(png_size(®ion_png), (120, 60)); + assert_eq!(png_size(&full_png), (80, full_height.ceil() as u32)); + + let live_after = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("unchanged live screenshot"); + assert_eq!(live_after, live_before); + let state = rt.state.borrow(); + let prepared = state.prepared_render.as_ref().expect("retained render"); + assert_eq!( + prepared as *const obscura_render::PreparedRender as usize, + prepared_address + ); + assert_eq!(state.viewport, viewport); + assert_eq!(state.scroll_offset, scroll_offset); + assert_eq!(state.scroll_generation, scroll_generation); + assert_eq!( + state + .resolved_scroll + .as_ref() + .expect("retained resolved scroll") + .1 + .root_offset(), + resolved_root + ); + assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1); + } + #[cfg(feature = "render")] #[test] fn script_registered_url_font_reaches_render_resource_collection() { @@ -8348,7 +8578,7 @@ mod tests { .await .unwrap_err(); assert!( - error.contains("Inline module evaluation timed out after 20ms"), + error.contains("Inline module evaluation timed out after"), "expected evaluation timeout, got: {}", error ); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 69440e9..9f9341d 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -93,11 +93,12 @@ mod image_capability_tests { mod paint; #[cfg(feature = "paint")] pub use paint::{ - paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_with_scroll, prepare_dom, - prepare_dom_with_dynamic_fonts, screenshot_png, screenshot_png_scrolled, - screenshot_prepared, screenshot_prepared_with_scroll, DynamicFontFace, + paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll, + paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, screenshot_png, + screenshot_png_scrolled, screenshot_prepared, screenshot_prepared_region_with_scroll, + screenshot_prepared_with_scroll, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader, - ResolvedScrollState, SelectedImage, + ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, }; // Real inline text layout (cosmic-text) lives behind the paint feature; the diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 8820a9b..8f601f9 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -354,6 +354,91 @@ pub struct ResolvedScrollState { inherited_clips: Vec>, } +/// A rectangle in immutable document CSS-pixel coordinates. +/// +/// `scale` controls output pixels per CSS pixel without changing the prepared +/// layout viewport. This is the same separation Chromium makes between the +/// page-space clip and the screenshot surface scale. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureRegion { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub scale: f32, +} + +impl CaptureRegion { + pub fn new(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Self { + Self { + x, + y, + width, + height, + scale, + } + } +} + +/// Hard surface limits for one capture. The byte cap bounds both the native +/// CSS-pixel raster and the scaled output before either allocation occurs. +pub const MAX_CAPTURE_DIMENSION: u32 = 32_768; +pub const MAX_CAPTURE_PIXELS: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaptureError { + InvalidRegion, + AllocationLimitExceeded, + PaintFailed, + EncodeFailed, +} + +fn checked_capture_dimensions( + region: CaptureRegion, +) -> Result<(u32, u32, u32, u32), CaptureError> { + if !region.x.is_finite() + || !region.y.is_finite() + || !region.width.is_finite() + || !region.height.is_finite() + || !region.scale.is_finite() + || region.width <= 0.0 + || region.height <= 0.0 + || region.scale <= 0.0 + { + return Err(CaptureError::InvalidRegion); + } + let native_width = region.width.ceil(); + let native_height = region.height.ceil(); + let output_width = (region.width * region.scale).ceil(); + let output_height = (region.height * region.scale).ceil(); + if !native_width.is_finite() + || !native_height.is_finite() + || !output_width.is_finite() + || !output_height.is_finite() + || native_width > MAX_CAPTURE_DIMENSION as f32 + || native_height > MAX_CAPTURE_DIMENSION as f32 + || output_width > MAX_CAPTURE_DIMENSION as f32 + || output_height > MAX_CAPTURE_DIMENSION as f32 + { + return Err(CaptureError::AllocationLimitExceeded); + } + let dimensions = ( + native_width as u32, + native_height as u32, + output_width as u32, + output_height as u32, + ); + for (width, height) in [ + (dimensions.0, dimensions.1), + (dimensions.2, dimensions.3), + ] { + if u64::from(width).saturating_mul(u64::from(height)) > MAX_CAPTURE_PIXELS { + return Err(CaptureError::AllocationLimitExceeded); + } + } + Ok(dimensions) +} + impl ResolvedScrollState { pub fn root_offset(&self) -> (f32, f32) { self.root_offset @@ -1419,6 +1504,7 @@ pub fn paint_prepared( None, None, None, + None, (0.0, 0.0), ) } @@ -1453,10 +1539,80 @@ pub fn paint_prepared_with_scroll( None, None, None, + None, (0.0, 0.0), ) } +/// Paint an arbitrary document-space rectangle from one retained layout and +/// resolved scroll snapshot. The live root viewport remains the containing +/// block for fixed and sticky descendants, while ordinary document content is +/// translated directly to the requested page-space origin. +pub fn paint_prepared_region_with_scroll( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, + region: CaptureRegion, +) -> Result { + let (native_width, native_height, output_width, output_height) = + checked_capture_dimensions(region)?; + let mut pixmap = Pixmap::new(native_width, native_height) + .ok_or(CaptureError::AllocationLimitExceeded)?; + pixmap.fill(Color::WHITE); + + // Resolved node movement already contains `-root_scroll` for ordinary + // document content and zero root movement for fixed content. Adding the + // live root offset back before subtracting the capture origin therefore + // maps ordinary nodes to page space while leaving fixed/sticky nodes at + // their live viewport's document-space position. + let root = scroll.root_offset(); + let surface_offset = (root.0 - region.x, root.1 - region.y); + let pixmap = paint_laid_dom_scrolled( + tree, + prepared.viewport, + prepared.base_url.as_deref(), + root, + Some(scroll), + pixmap, + resources, + &prepared.selected_images, + &prepared.svg_fonts, + prepared.content_size, + &prepared.viewport_fixed, + &prepared.sticky, + &mut prepared.layout, + None, + None, + None, + None, + None, + Some((region.width, region.height)), + surface_offset, + ) + .ok_or(CaptureError::PaintFailed)?; + + if output_width == native_width && output_height == native_height { + return Ok(pixmap); + } + + // The retained painter currently rasterizes glyphs and decoded images in + // CSS-pixel space. Keep scale out of layout and scroll state, then use one + // premultiplied-alpha high-quality surface transform. Vector primitives + // still take the direct raster path whenever scale is 1. + let source = image::RgbaImage::from_raw(native_width, native_height, pixmap.data().to_vec()) + .ok_or(CaptureError::PaintFailed)?; + let scaled = image::imageops::resize( + &source, + output_width, + output_height, + image::imageops::FilterType::Lanczos3, + ); + let size = tiny_skia::IntSize::from_wh(output_width, output_height) + .ok_or(CaptureError::AllocationLimitExceeded)?; + Pixmap::from_vec(scaled.into_raw(), size).ok_or(CaptureError::PaintFailed) +} + /// The used `z-index` for an element which participates in stacking order. /// /// A non-auto z-index applies not only to positioned boxes, but also to @@ -1574,6 +1730,7 @@ fn paint_laid_dom_scrolled( suppress_stacking_for: Option, suppress_transform_for: Option, clip_scope_root: Option, + surface_extent: Option<(f32, f32)>, surface_offset: (f32, f32), ) -> Option { let scroll_state = match resolved_scroll { @@ -1583,6 +1740,7 @@ fn paint_laid_dom_scrolled( viewport_fixed, resolved, clip_scope_root, + surface_extent, surface_offset, ), None => ScrollPaintState::new( @@ -1593,6 +1751,7 @@ fn paint_laid_dom_scrolled( viewport_fixed, sticky, clip_scope_root, + surface_extent, surface_offset, ), }; @@ -1778,6 +1937,7 @@ fn paint_laid_dom_scrolled( Some(nid), suppress_transform_for, clip_scope_root, + surface_extent, surface_offset, )?; continue; @@ -1859,6 +2019,7 @@ fn paint_laid_dom_scrolled( suppress_stacking_for, Some(nid), clip_scope_root, + surface_extent, surface_offset, )?; continue; @@ -1928,6 +2089,7 @@ fn paint_laid_dom_scrolled( suppress_stacking_for, Some(nid), Some(nid), + surface_extent, ( surface_offset.0 + layer_delta.0, surface_offset.1 + layer_delta.1, @@ -1994,6 +2156,7 @@ fn paint_laid_dom_scrolled( suppress_stacking_for, suppress_transform_for, clip_scope_root, + surface_extent, surface_offset, )?; let group_paint = tiny_skia::PixmapPaint { @@ -2827,6 +2990,7 @@ struct ScrollPaintState<'a> { sticky_clips: std::collections::HashMap, resolved: Option<&'a ResolvedScrollState>, clip_scope_root: Option, + surface_extent: Option<(f32, f32)>, surface_offset: (f32, f32), active: bool, } @@ -2840,6 +3004,7 @@ impl<'a> ScrollPaintState<'a> { viewport_fixed: &'a std::collections::HashSet, sticky_layout: &crate::StickyLayout, clip_scope_root: Option, + surface_extent: Option<(f32, f32)>, surface_offset: (f32, f32), ) -> Self { let scroll_x = if requested.0.is_finite() { @@ -2870,6 +3035,7 @@ impl<'a> ScrollPaintState<'a> { sticky_clips: std::collections::HashMap::new(), resolved: None, clip_scope_root, + surface_extent, surface_offset, active, }; @@ -2886,6 +3052,7 @@ impl<'a> ScrollPaintState<'a> { sticky_clips, resolved: None, clip_scope_root, + surface_extent, surface_offset, active, } @@ -2897,6 +3064,7 @@ impl<'a> ScrollPaintState<'a> { viewport_fixed: &'a std::collections::HashSet, resolved: &'a ResolvedScrollState, clip_scope_root: Option, + surface_extent: Option<(f32, f32)>, surface_offset: (f32, f32), ) -> Self { Self { @@ -2908,6 +3076,7 @@ impl<'a> ScrollPaintState<'a> { sticky_clips: std::collections::HashMap::new(), resolved: Some(resolved), clip_scope_root, + surface_extent, surface_offset, active: true, } @@ -2942,7 +3111,7 @@ impl<'a> ScrollPaintState<'a> { id: obscura_dom::tree::NodeId, ) -> Option { self.overflow_clip_for(laid, id) - .map(|clip| clip.viewport_rect(self.viewport)) + .map(|clip| clip.viewport_rect(self.surface_extent.unwrap_or(self.viewport))) } fn overflow_clip_for( @@ -3015,7 +3184,8 @@ impl<'a> ScrollPaintState<'a> { let inherited = self.overflow_clip_for(laid, id); let style = laid.styles.get(&id)?; if !style.overflow_hidden || style.overflow_propagated_to_viewport { - return inherited.map(|clip| clip.viewport_rect(self.viewport)); + return inherited + .map(|clip| clip.viewport_rect(self.surface_extent.unwrap_or(self.viewport))); } let rect = laid.rects.get(&id)?; let (ox, oy) = self.translation_for(laid, id); @@ -3025,7 +3195,7 @@ impl<'a> ScrollPaintState<'a> { Some(clip) => clip.intersect(own), None => own, } - .viewport_rect(self.viewport), + .viewport_rect(self.surface_extent.unwrap_or(self.viewport)), ) } @@ -3767,6 +3937,19 @@ pub fn screenshot_prepared_with_scroll( .ok() } +/// PNG convenience wrapper for [`paint_prepared_region_with_scroll`]. +pub fn screenshot_prepared_region_with_scroll( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, + region: CaptureRegion, +) -> Result, CaptureError> { + paint_prepared_region_with_scroll(tree, prepared, resources, scroll, region)? + .encode_png() + .map_err(|_| CaptureError::EncodeFailed) +} + /// A representative visible color for `background-clip: text` text whose own /// color is transparent, used on the word-split paint path (the cosmic-text IFC /// path samples the gradient per glyph in `inline`). Returns the gradient's mid @@ -10092,4 +10275,148 @@ mod tests { "newly visible nested content did not move into the scrollport" ); } + + #[test] + fn document_region_capture_reuses_layout_scroll_and_resources() { + let tree = parse_html( + r#" +
+
+
+
+ +
+ "#, + ); + let loads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let loader_loads = loads.clone(); + let mut resources = RenderResourceCache::with_loader(move |url: &str| { + assert_eq!(url, "https://example.test/fixture.svg"); + loader_loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some( + br##" + + "## + .to_vec(), + ) + }); + let mut prepared = prepare_dom( + &tree, + (80.0, 60.0), + Some("https://example.test/page"), + &mut resources, + ) + .expect("prepared render"); + assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1); + let scroll = prepared.resolve_scroll_state(&tree, (0.0, 100.0), &HashMap::new()); + let fixed = tree.get_element_by_id("fixed").expect("fixed"); + let before_fixed = prepared + .viewport_rect_with_scroll(fixed, &scroll) + .expect("fixed geometry"); + + let offscreen = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 100.0, 80.0, 80.0, 1.0), + ) + .expect("offscreen region"); + assert_eq!((offscreen.width(), offscreen.height()), (80, 80)); + let fixed_pixel = offscreen.pixel(5, 5).expect("fixed pixel"); + assert!(fixed_pixel.blue() > 240 && fixed_pixel.red() < 20); + let sticky_pixel = offscreen.pixel(25, 5).expect("sticky pixel"); + assert!(sticky_pixel.red() > 240 && sticky_pixel.green() > 240); + let target_pixel = offscreen.pixel(45, 55).expect("offscreen target"); + assert!(target_pixel.red() > 240 && target_pixel.blue() > 240); + + let full_height = prepared.content_size().1; + let full = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 80.0, full_height, 1.0), + ) + .expect("full-content region"); + assert_eq!(full.height(), full_height.ceil() as u32); + let fixed_at_live_viewport = full.pixel(5, 105).expect("full fixed pixel"); + assert!(fixed_at_live_viewport.blue() > 240 && fixed_at_live_viewport.red() < 20); + let fixed_not_duplicated = full.pixel(5, 5).expect("document top pixel"); + assert!(fixed_not_duplicated.red() > 240 && fixed_not_duplicated.blue() < 20); + + let scaled = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(40.0, 150.0, 10.0, 10.0, 2.0), + ) + .expect("scaled region"); + assert_eq!((scaled.width(), scaled.height()), (20, 20)); + let scaled_center = scaled.pixel(10, 10).expect("scaled center"); + assert!(scaled_center.red() > 240 && scaled_center.blue() > 240); + + assert_eq!(scroll.root_offset(), (0.0, 100.0)); + assert_eq!( + prepared + .viewport_rect_with_scroll(fixed, &scroll) + .expect("unchanged fixed geometry"), + before_fixed + ); + assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[test] + fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() { + let tree = parse_html("
"); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut prepared = prepare_dom(&tree, (20.0, 20.0), None, &mut resources) + .expect("prepared render"); + let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); + + for invalid in [ + CaptureRegion::new(0.0, 0.0, 0.0, 10.0, 1.0), + CaptureRegion::new(f32::NAN, 0.0, 10.0, 10.0, 1.0), + CaptureRegion::new(0.0, 0.0, 10.0, 10.0, f32::INFINITY), + ] { + assert_eq!( + paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + invalid, + ) + .unwrap_err(), + CaptureError::InvalidRegion + ); + } + assert_eq!( + paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, MAX_CAPTURE_DIMENSION as f32, 10.0, 2.0), + ) + .unwrap_err(), + CaptureError::AllocationLimitExceeded + ); + assert_eq!( + paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 9000.0, 9000.0, 1.0), + ) + .unwrap_err(), + CaptureError::AllocationLimitExceeded + ); + } }