From 9f33db610eb56dfffdaf605a92b1dd1fbc705d66 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:41:10 +0000 Subject: [PATCH] test: align paired browser settle timing --- crates/obscura-browser/src/page.rs | 23 ++++++++++++++++++----- crates/obscura-cli/src/main.rs | 13 ++++++++++++- render-repros/paired-corpus.py | 11 ++++++++++- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 676e80e..da31055 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -1242,11 +1242,24 @@ impl Page { } let hydration_snapshot = self.js.as_ref().and_then(capture_hydration_snapshot); if let Some(js) = &mut self.js { - // Bounded against both async idle and synchronous microtask storms: - // a plain tokio timeout cannot preempt a page that pins the thread - // inside V8 (the real-world SPA hang), so settle drives the loop - // through the watchdog-guarded path. - let _ = js.run_event_loop_bounded(max_ms).await; + if std::env::var_os("OBSCURA_STRICT_SETTLE").is_some() { + // Paired browser measurements need the same wall-clock interval + // after load in both engines. The ordinary product path returns + // as soon as the loop is idle for speed; strict mode pumps all + // pending work, then retains the remaining wall-clock delay. + let started = tokio::time::Instant::now(); + let _ = js.run_event_loop_bounded(max_ms).await; + let requested = tokio::time::Duration::from_millis(max_ms); + let elapsed = started.elapsed(); + if elapsed < requested { + tokio::time::sleep(requested - elapsed).await; + } + } else { + // Bounded against both async idle and synchronous microtask + // storms: a plain tokio timeout cannot preempt a page that pins + // the thread inside V8, so settle uses the watchdog path. + let _ = js.run_event_loop_bounded(max_ms).await; + } if let Some(before) = hydration_snapshot { let _ = restore_hydration_snapshot_if_needed(js, before); diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 23c72f1..6e6be9a 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -970,7 +970,18 @@ async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) - return false; } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + // The selector may be created by a timer, dynamic import, or fetch + // completion. Sleeping without pumping V8 makes those callbacks unable + // to run, so a valid selector wait always times out. Drive one bounded + // event-loop slice, then retain a 100ms polling cadence if it returned + // idle immediately. + let slice_started = tokio::time::Instant::now(); + page.settle(100).await; + let spent = slice_started.elapsed(); + let cadence = tokio::time::Duration::from_millis(100); + if spent < cadence { + tokio::time::sleep(cadence - spent).await; + } } } diff --git a/render-repros/paired-corpus.py b/render-repros/paired-corpus.py index 85573bd..8c33bb3 100755 --- a/render-repros/paired-corpus.py +++ b/render-repros/paired-corpus.py @@ -54,6 +54,13 @@ def capture_obscura(binary, url, screenshot, log, width, height, settle_ms): OBSCURA_SHOT_W=str(width), OBSCURA_SHOT_H=str(height), OBSCURA_ALLOW_PRIVATE_NETWORK="1", + # Product fetches return early when the event loop becomes idle. Paired + # captures instead need the same complete post-load wall interval that + # Playwright's wait_for_timeout below uses. + OBSCURA_STRICT_SETTLE="1", + # Match Playwright's 50-second goto allowance. This is the browser + # engine's millisecond ceiling, distinct from the CLI's seconds unit. + OBSCURA_NAV_TIMEOUT_MS="50000", ) command = [ binary, @@ -62,7 +69,7 @@ def capture_obscura(binary, url, screenshot, log, width, height, settle_ms): "--screenshot", str(screenshot), "--timeout", - "50000", + "50", "--wait", f"{settle_ms / 1000:g}", ] @@ -119,6 +126,8 @@ def main(): "started_utc": datetime.now(timezone.utc).isoformat(), "viewport": {"width": args.width, "height": args.height, "dpr": 1}, "settle_ms_after_load": args.settle_ms, + "settle_semantics": "full wall-clock interval while pumping each engine", + "navigation_timeout_ms": 50000, "obscura": binary_version(args.obscura_bin), "baseline": binary_version(args.baseline_bin) if args.baseline_bin else None, "pages": [],