From 65ed38a71d2cce26ddced47e835d8ad9db927838 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:11:26 +0000 Subject: [PATCH] Warm renderer resources before parity sampling --- crates/obscura-cli/src/main.rs | 29 ++++++++++-- .../tests/screenshot_after_eval.rs | 10 ++++ render-repros/paired-corpus.py | 46 ++++++++++++++++--- render-repros/test_paired_corpus.py | 21 +++++++++ 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 153e538..4a0d186 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -781,6 +781,25 @@ async fn run_fetch( if let Some(ref path) = screenshot { #[cfg(feature = "render")] { + // 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). + let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0)); + // The parity harness performs one throwaway paint in both engines + // before observing image/font readiness. Obscura resolves retained + // render resources during prepare/paint, so sampling first would + // compare pre-paint Obscura state with post-load Chromium state. + // Keep this private opt-in out of ordinary CLI screenshots. + let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP") + .is_ok_and(|value| value == "1"); + if warmup_capture { + if page.screenshot(viewport).is_none() { + anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render"); + } + // Give completion callbacks one bounded task turn before the + // capture-boundary evaluation reads resource state. + page.settle(1).await; + } // Paired renderer captures need a stable final coordinate after // the post-eval settle. Authored smooth scrolling and scroll // anchoring may legitimately move an earlier scrollTo while the @@ -827,10 +846,6 @@ async fn run_fetch( }))()", ) }); - // 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). - let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0)); match page.screenshot(viewport) { Some(bytes) => std::fs::write(path, &bytes)?, None => anyhow::bail!("screenshot failed: page has no DOM to render"), @@ -865,6 +880,12 @@ async fn run_fetch( serde_json::json!({ "evaluation": result, "controlledScroll": controlled_scroll_report, + "resourceWarmup": { + "performed": warmup_capture, + "discardedShots": if warmup_capture { 1 } else { 0 }, + "taskTurnMs": if warmup_capture { 1 } else { 0 }, + "phase": "before-final-scroll-reassert-and-state-sample", + }, "captureState": capture_state.unwrap_or(serde_json::Value::Null), }) ); diff --git a/crates/obscura-cli/tests/screenshot_after_eval.rs b/crates/obscura-cli/tests/screenshot_after_eval.rs index 4b9ffc8..d76b72e 100644 --- a/crates/obscura-cli/tests/screenshot_after_eval.rs +++ b/crates/obscura-cli/tests/screenshot_after_eval.rs @@ -155,6 +155,7 @@ fn paired_capture_evaluates_state_after_final_scroll_reassert() { .env("OBSCURA_SHOT_SCROLL_X", "0") .env("OBSCURA_SHOT_SCROLL_Y", "80") .env("OBSCURA_SHOT_EVAL_AT_CAPTURE", "1") + .env("OBSCURA_SHOT_RESOURCE_WARMUP", "1") .output() .expect("run obscura fetch"); assert!( @@ -188,6 +189,15 @@ fn paired_capture_evaluates_state_after_final_scroll_reassert() { report["controlledScroll"]["phase"].as_str(), Some("immediately-before-capture-state-and-screenshot") ); + assert_eq!(report["resourceWarmup"]["performed"].as_bool(), Some(true)); + assert_eq!( + report["resourceWarmup"]["discardedShots"].as_u64(), + Some(1) + ); + assert_eq!( + report["resourceWarmup"]["phase"].as_str(), + Some("before-final-scroll-reassert-and-state-sample") + ); std::fs::remove_dir_all(directory).expect("remove temporary output directory"); } diff --git a/render-repros/paired-corpus.py b/render-repros/paired-corpus.py index b344aee..96d3fbb 100755 --- a/render-repros/paired-corpus.py +++ b/render-repros/paired-corpus.py @@ -64,6 +64,7 @@ BRAND_PERMUTATIONS = [ ] GEOMETRY_PROBE_RECT_LIMIT = 200 CAPTURE_BOUNDARY_PHASE = "capture-boundary-before-screenshot" +RESOURCE_WARMUP_PHASE = "before-final-scroll-reassert-and-state-sample" def geometry_probe_javascript(selectors_expression): @@ -360,6 +361,8 @@ def parse_obscura_capture_report(stdout): state["geometry"] = geometry if isinstance(report.get("controlledScroll"), dict): state["controlled_scroll_capture"] = report["controlledScroll"] + if isinstance(report.get("resourceWarmup"), dict): + state["resource_warmup"] = report["resourceWarmup"] state["evaluation_sampled_phase"] = state.get("sampled_phase") state["screenshot_sampled_phase"] = CAPTURE_BOUNDARY_PHASE state["state_and_screenshot_share_capture_boundary"] = ( @@ -434,6 +437,10 @@ def obscura_environment(width, height): # all settle phases and the final scroll reassertion. No event-loop # pumping occurs between that evaluation and screenshot paint. OBSCURA_SHOT_EVAL_AT_CAPTURE="1", + # Resolve the exact retained image/font graph with one throwaway paint + # before resource-readiness state is sampled. Chromium performs the + # same warm-up shot in the paired process. + OBSCURA_SHOT_RESOURCE_WARMUP="1", ) return env @@ -952,6 +959,19 @@ def capture_chromium_image(page, screenshot, geometry_selectors=None): return state, boundary +def warm_chromium_capture(page): + """Resolve paint-time resources before capture-boundary state sampling.""" + page.screenshot(full_page=False, timeout=50000) + # Yield one bounded browser task turn, matching the Obscura CLI warm-up. + page.wait_for_timeout(1) + return { + "performed": True, + "discardedShots": 1, + "taskTurnMs": 1, + "phase": RESOURCE_WARMUP_PHASE, + } + + def freeze_chromium_animations(page, sample_ms): """Pause currently exposed Web Animations at one explicit local time. @@ -1278,17 +1298,21 @@ def main(): }, "state_observability": { "capture_boundary_phase": CAPTURE_BOUNDARY_PHASE, + "resource_warmup_phase": RESOURCE_WARMUP_PHASE, "chromium": ( - "same page, synchronously sampled after all settle phases and " + "same page, one discard paint resolves the retained resource " + "graph, then one bounded task turn and the final scroll " + "reassert run before synchronous state sampling. State is " "immediately before screenshot. A second synchronous sample " "after paint brackets the PNG and reports whether capture-" "critical geometry/readiness remained stable. DOM SHA-256 " "finalization runs on the host only after paint" ), "obscura": ( - "the paired-capture CLI defers DOM/resource/selector evaluation " - "until after all settle phases and the final instant scroll " - "reassert. Evaluation, captureState, and screenshot then run " + "the paired-capture CLI takes one discard paint over the same " + "live page, pumps one bounded task turn, then performs the " + "final instant scroll reassert before deferred DOM/resource/" + "selector evaluation. Evaluation, captureState, and screenshot run " "consecutively without another settle; captureState records the " "exact shared PreparedRender viewport, scroll offset, and " "content size used by paint" @@ -1318,9 +1342,12 @@ def main(): "serialize fetched stylesheet text into outerHTML." ), "resource_readiness": ( - "Obscura's DOM image/font readiness is sampled before paint; " - "paint may then fetch retained renderer resources that are not " - "reflected back into HTMLImageElement or FontFaceSet state." + "Both engines take and discard one screenshot before sampling " + "resource readiness, then yield one bounded task turn. This " + "warms the exact retained render graph instead of comparing " + "Obscura's pre-paint state with Chromium's post-load state. " + "A successful fetch is still not proof of equal decode, selected " + "source, or paint output." ), "animation_sampling": ( "The deterministic option controls only animations still " @@ -1452,6 +1479,7 @@ def main(): chrome_started = time.time() chromium_scroll_state = None chromium_capture_boundary = None + chromium_resource_warmup = None try: page.goto(url, wait_until="load", timeout=50000) page.wait_for_timeout(args.settle_ms) @@ -1472,6 +1500,7 @@ def main(): animation_sampling = freeze_chromium_animations( page, args.animation_time_ms ) + chromium_resource_warmup = warm_chromium_capture(page) if controlled_scroll is not None: chromium_scroll_state = ( reassert_chromium_controlled_scroll( @@ -1538,6 +1567,9 @@ def main(): "capture_boundary_validation": ( chromium_capture_boundary if chrome_ok else None ), + "resource_warmup": ( + chromium_resource_warmup if chrome_ok else None + ), "scroll_state": ( chromium_scroll_state if chrome_ok else None ), diff --git a/render-repros/test_paired_corpus.py b/render-repros/test_paired_corpus.py index 8beebb7..985a099 100644 --- a/render-repros/test_paired_corpus.py +++ b/render-repros/test_paired_corpus.py @@ -118,6 +118,7 @@ class ControlledScrollTests(unittest.TestCase): self.assertEqual(environment["OBSCURA_SHOT_SCROLL_X"], "12") self.assertEqual(environment["OBSCURA_SHOT_SCROLL_Y"], "bottom") self.assertEqual(environment["OBSCURA_SHOT_EVAL_AT_CAPTURE"], "1") + self.assertEqual(environment["OBSCURA_SHOT_RESOURCE_WARMUP"], "1") def test_paired_state_expression_is_read_only_at_capture_boundary(self): expression = paired_corpus.obscura_state_eval_expression( @@ -326,6 +327,26 @@ class GeometryProbeTests(unittest.TestCase): hashlib.sha256(b"hello").hexdigest(), ) + def test_chromium_resource_warmup_discards_one_shot_then_yields(self): + events = [] + + class WarmupPage: + def screenshot(self, **kwargs): + events.append(("screenshot", kwargs)) + return b"discarded" + + def wait_for_timeout(self, timeout): + events.append(("wait", timeout)) + + report = paired_corpus.warm_chromium_capture(WarmupPage()) + self.assertEqual([event[0] for event in events], ["screenshot", "wait"]) + self.assertEqual(events[1], ("wait", 1)) + self.assertNotIn("path", events[0][1]) + self.assertEqual(report["discardedShots"], 1) + self.assertEqual( + report["phase"], paired_corpus.RESOURCE_WARMUP_PHASE + ) + def test_repeatable_selectors_are_passed_safely_in_one_state_expression(self): selectors = ["header nav a", '[data-label="a\\\"b"]', "["] obscura_expression = paired_corpus.obscura_state_eval_expression(