diff --git a/crates/ely_servo_host/tests/live_perf_bench.rs b/crates/ely_servo_host/tests/live_perf_bench.rs
new file mode 100644
index 0000000..e8e6b79
--- /dev/null
+++ b/crates/ely_servo_host/tests/live_perf_bench.rs
@@ -0,0 +1,292 @@
+//! Manually-invoked sidecar perf bench. Runs the live loop, scrolls a
+//! tall page over N frames, and harvests the [`FramePerfSummary`]
+//! entries the sidecar emits every window. Invoked once per kind:
+//!
+//! ```text
+//! ELY_PERF_KIND=software ELY_PERF_FRAMES=240 \
+//! cargo test -p ely_servo_host --release --test live_perf_bench \
+//! --features servo-engine -- --ignored --nocapture run_live_bench
+//!
+//! ELY_PERF_KIND=hardware ELY_PERF_FRAMES=240 \
+//! cargo test -p ely_servo_host --release --test live_perf_bench \
+//! --features servo-engine,hardware-render -- --ignored --nocapture run_live_bench
+//! ```
+//!
+//! Marked `#[ignore]` so normal CI skips it; it shells out to the
+//! sidecar binary and runs for tens of seconds.
+#![cfg(feature = "servo-engine")]
+
+use std::{
+ env,
+ error::Error,
+ fs,
+ io::{BufRead, BufReader, Read, Write},
+ path::PathBuf,
+ process::{Child, ChildStdin, ChildStdout, Command, Stdio},
+ thread,
+ time::{Duration, Instant},
+};
+
+use ely_domain::{ProfileId, TabId};
+use serde::Deserialize;
+
+const DEFAULT_FRAMES: u32 = 240;
+const VIEWPORT_WIDTH: u32 = 1024;
+const VIEWPORT_HEIGHT: u32 = 768;
+const SCROLL_STEP_PX: i32 = 4;
+const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
+const SCROLL_PAGE_DATA_URL: &str = "data:text/html,\
+
perf\
+\
+";
+
+#[derive(Deserialize, Debug)]
+struct LiveResponse {
+ error: Option,
+ frame: Option,
+ #[serde(default)]
+ perf: Option,
+}
+
+#[derive(Deserialize, Debug)]
+struct LiveFrameReport {
+ rgba_byte_count: usize,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+struct FramePerfSummary {
+ window: u32,
+ context: String,
+ paint_p50_us: u64,
+ paint_p95_us: u64,
+ paint_p99_us: u64,
+ encode_p50_us: u64,
+ encode_p95_us: u64,
+ encode_p99_us: u64,
+ write_p50_us: u64,
+ write_p95_us: u64,
+ write_p99_us: u64,
+ total_p50_us: u64,
+ total_p95_us: u64,
+ total_p99_us: u64,
+}
+
+#[test]
+#[ignore = "manual bench: spawns sidecar, scrolls a data: URL for N frames"]
+fn run_live_bench() -> Result<(), Box> {
+ let kind = env::var("ELY_PERF_KIND").unwrap_or_else(|_| "software".to_string());
+ let frames: u32 = env::var("ELY_PERF_FRAMES")
+ .ok()
+ .and_then(|value| value.parse().ok())
+ .unwrap_or(DEFAULT_FRAMES);
+ let url = env::var("ELY_PERF_URL").unwrap_or_else(|_| SCROLL_PAGE_DATA_URL.to_string());
+
+ let profile_id = ProfileId::new();
+ let tab = TabId::new();
+ let profile_data_dir = env::temp_dir().join(format!(
+ "ely-perf-bench-{}-{}-{}",
+ std::process::id(),
+ kind,
+ profile_id.as_str()
+ ));
+ fs::create_dir_all(&profile_data_dir)?;
+
+ let mut child = spawn_sidecar(&kind, &profile_data_dir)?;
+ let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
+ let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
+ let mut reader = BufReader::new(stdout);
+
+ let summaries =
+ match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames) {
+ Ok(summaries) => summaries,
+ Err(error) => {
+ drop(stdin);
+ let _ = child.kill();
+ cleanup(&profile_data_dir)?;
+ return Err(error);
+ }
+ };
+
+ drop(stdin);
+ let _ = child.wait();
+ cleanup(&profile_data_dir)?;
+
+ print_summaries(&kind, frames, &summaries);
+ assert!(
+ !summaries.is_empty(),
+ "expected at least one FramePerfSummary across {frames} frames"
+ );
+ Ok(())
+}
+
+fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result> {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
+ command
+ .arg("live")
+ .arg("--profile-data-dir")
+ .arg(profile_data_dir)
+ .arg("--rendering-context")
+ .arg(kind)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::inherit());
+ Ok(command.spawn()?)
+}
+
+fn drive_bench(
+ stdin: &mut ChildStdin,
+ reader: &mut BufReader,
+ kind: &str,
+ tab: &TabId,
+ profile_id: &ProfileId,
+ url: &str,
+ frames: u32,
+) -> Result, Box> {
+ let mut summaries = Vec::new();
+
+ let navigate = build_ensure(tab, profile_id, url, 0, 0, false);
+ write_request(stdin, &navigate)?;
+ let response = read_response(reader, RESPONSE_TIMEOUT)?;
+ record_summary(&response, kind, &mut summaries);
+
+ let mut accumulated_scroll = 0;
+ for frame_index in 0..frames {
+ let scroll_delta_y = if frame_index % 80 == 79 {
+ -SCROLL_STEP_PX * 60
+ } else {
+ SCROLL_STEP_PX
+ };
+ accumulated_scroll += scroll_delta_y;
+ let request = build_ensure(tab, profile_id, url, 0, scroll_delta_y, true);
+ write_request(stdin, &request)?;
+ let response = read_response(reader, RESPONSE_TIMEOUT)?;
+ if let Some(error) = response.error.as_ref() {
+ return Err(format!("sidecar error at frame {frame_index}: {error}").into());
+ }
+ record_summary(&response, kind, &mut summaries);
+ }
+ let _ = accumulated_scroll;
+
+ Ok(summaries)
+}
+
+fn build_ensure(
+ tab: &TabId,
+ profile_id: &ProfileId,
+ url: &str,
+ scroll_dx: i32,
+ scroll_dy: i32,
+ include_hover: bool,
+) -> String {
+ let hover_x = if include_hover { Some(256u32) } else { None };
+ let hover_y = if include_hover { Some(256u32) } else { None };
+ let hover_x_json = match hover_x {
+ Some(value) => format!("{value}"),
+ None => "null".to_string(),
+ };
+ let hover_y_json = match hover_y {
+ Some(value) => format!("{value}"),
+ None => "null".to_string(),
+ };
+ format!(
+ r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#,
+ tab = tab.as_str(),
+ profile = profile_id.as_str(),
+ url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()),
+ w = VIEWPORT_WIDTH,
+ h = VIEWPORT_HEIGHT,
+ dx = scroll_dx,
+ dy = scroll_dy,
+ hx = hover_x_json,
+ hy = hover_y_json,
+ )
+}
+
+fn write_request(stdin: &mut ChildStdin, request: &str) -> Result<(), Box> {
+ stdin.write_all(request.as_bytes())?;
+ stdin.write_all(b"\n")?;
+ stdin.flush()?;
+ Ok(())
+}
+
+fn read_response(
+ reader: &mut BufReader,
+ timeout: Duration,
+) -> Result> {
+ let started_at = Instant::now();
+ let mut json_line = String::new();
+ loop {
+ json_line.clear();
+ let read_bytes = reader.read_line(&mut json_line)?;
+ if read_bytes == 0 {
+ return Err("sidecar closed stdout".into());
+ }
+ if json_line.trim().is_empty() {
+ if started_at.elapsed() >= timeout {
+ return Err("sidecar response timeout".into());
+ }
+ thread::sleep(Duration::from_millis(2));
+ continue;
+ }
+ break;
+ }
+ let response: LiveResponse = serde_json::from_str(json_line.trim_end())?;
+ if let Some(frame) = response.frame.as_ref() {
+ if frame.rgba_byte_count > 0 {
+ let mut scratch = vec![0u8; frame.rgba_byte_count];
+ reader.read_exact(&mut scratch)?;
+ }
+ }
+ Ok(response)
+}
+
+fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec) {
+ if let Some(perf) = response.perf.as_ref() {
+ assert_eq!(
+ perf.context, kind,
+ "sidecar context label must match requested kind"
+ );
+ eprintln!(
+ "[perf {kind}] window={} paint p50/p95/p99={}/{}/{} encode {}/{}/{} write {}/{}/{} total {}/{}/{} (µs)",
+ perf.window,
+ perf.paint_p50_us, perf.paint_p95_us, perf.paint_p99_us,
+ perf.encode_p50_us, perf.encode_p95_us, perf.encode_p99_us,
+ perf.write_p50_us, perf.write_p95_us, perf.write_p99_us,
+ perf.total_p50_us, perf.total_p95_us, perf.total_p99_us,
+ );
+ summaries.push(perf.clone());
+ }
+}
+
+fn print_summaries(kind: &str, frames: u32, summaries: &[FramePerfSummary]) {
+ eprintln!("\n=== ELY_PERF_KIND={kind} frames={frames} windows={} ===", summaries.len());
+ eprintln!(
+ "{:<8} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
+ "win",
+ "paint50", "paint95", "paint99",
+ "enc50", "enc95", "enc99",
+ "wr50", "wr95", "wr99",
+ "tot50", "tot95", "tot99",
+ );
+ for summary in summaries {
+ eprintln!(
+ "{:<8} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
+ summary.window,
+ summary.paint_p50_us, summary.paint_p95_us, summary.paint_p99_us,
+ summary.encode_p50_us, summary.encode_p95_us, summary.encode_p99_us,
+ summary.write_p50_us, summary.write_p95_us, summary.write_p99_us,
+ summary.total_p50_us, summary.total_p95_us, summary.total_p99_us,
+ );
+ }
+}
+
+fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box> {
+ match fs::remove_dir_all(profile_data_dir) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error.into()),
+ }
+}