Move Servo IPC off UI thread

Root cause of the post-tab lag: the GPUI 16 ms timer was calling
`WebSurfaceRuntime::ensure_tab` and `tick` on the UI thread, and each
call did a synchronous `serde_json` write plus `read_line` against the
Servo sidecar over stdin/stdout. With even one visible tab, every
frame stalled on cross-process IPC.

Introduce `web_surface_worker.rs` — a per-profile worker thread that
owns the `ServoLiveClient`, drains a coalescing request queue
(latest Ensure/Poll per tab wins, no unbounded growth), and ships
results back through a `std::sync::mpsc` channel. `WebSurfaceRuntime`
now submits work non-blockingly and drains responses in `tick`; the
UI thread never blocks on the sidecar.

Adjacent in-flight cleanup riding along: hardware IOSurface
rendering-context completion (sidecar `live_protocol`,
`hardware_rendering_context`, GPUI BGRA surface shader), CSS viewport
size + device pixel ratio plumbing into `ServoLiveFrame`, and the
Send opt-ins for `CVPixelBuffer`-bearing types so frames can cross
the thread boundary.
This commit is contained in:
2026-05-15 16:41:40 -04:00
parent f4c650c4d8
commit 90c029eddb
29 changed files with 2113 additions and 496 deletions
+117 -64
View File
@@ -82,6 +82,12 @@ struct LiveFrameReport {
width: u32,
#[serde(default)]
height: u32,
#[serde(default)]
device_pixel_ratio: f32,
#[serde(default)]
css_viewport_width: u32,
#[serde(default)]
css_viewport_height: u32,
}
#[derive(Deserialize, Debug, Clone)]
@@ -127,16 +133,16 @@ fn run_live_bench() -> Result<(), Box<dyn Error>> {
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
let mut reader = BufReader::new(stdout);
let outcome =
match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames) {
Ok(outcome) => outcome,
Err(error) => {
drop(stdin);
let _ = child.kill();
cleanup(&profile_data_dir)?;
return Err(error);
}
};
let outcome = match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames)
{
Ok(outcome) => outcome,
Err(error) => {
drop(stdin);
let _ = child.kill();
cleanup(&profile_data_dir)?;
return Err(error);
}
};
drop(stdin);
let _ = child.wait();
@@ -146,8 +152,8 @@ fn run_live_bench() -> Result<(), Box<dyn Error>> {
print_surface_handles(&kind, &outcome.surface_handles);
print_current_surface_summary(&kind, &outcome.current_surface_ids);
eprintln!(
"\n=== ELY_PERF_KIND={kind} rgba_bytes_received={} ===",
outcome.rgba_bytes_received
"\n=== ELY_PERF_KIND={kind} bootstrap_rgba_bytes={} steady_state_rgba_bytes={} ===",
outcome.bootstrap_rgba_bytes, outcome.steady_state_rgba_bytes,
);
assert!(
!outcome.summaries.is_empty(),
@@ -174,17 +180,10 @@ fn run_live_bench() -> Result<(), Box<dyn Error>> {
!outcome.current_surface_ids.is_empty(),
"hardware path must report current_surface_id on every frame"
);
// T10.6: once the receiver samples the IOSurface directly,
// the sidecar drops the RGBA payload. The initial navigate
// response may still carry bytes (no current_surface_id yet
// because surfman hasn't bound the painted surface), but the
// steady-state per-frame cost must be zero.
assert!(
outcome.rgba_bytes_received < (frames as u64) * 1_024,
"hardware path leaked {} RGBA bytes across {} frames \
(expected ~0 — the wire-drop optimisation regressed)",
outcome.rgba_bytes_received,
frames + 1,
assert_eq!(
outcome.steady_state_rgba_bytes, 0,
"hardware path leaked {} RGBA bytes while scrolling",
outcome.steady_state_rgba_bytes,
);
} else {
assert!(
@@ -198,10 +197,11 @@ fn run_live_bench() -> Result<(), Box<dyn Error>> {
// Software path keeps streaming pixels — every frame must
// carry a full RGBA payload.
let viewport_bytes = (1024u64) * (768u64) * 4;
let total_rgba_bytes = outcome.bootstrap_rgba_bytes + outcome.steady_state_rgba_bytes;
assert!(
outcome.rgba_bytes_received >= viewport_bytes,
total_rgba_bytes >= viewport_bytes,
"software path delivered only {} bytes — expected at least one full frame ({})",
outcome.rgba_bytes_received,
total_rgba_bytes,
viewport_bytes,
);
}
@@ -212,7 +212,8 @@ struct BenchOutcome {
summaries: Vec<FramePerfSummary>,
surface_handles: Vec<BenchSurfaceHandle>,
current_surface_ids: Vec<u64>,
rgba_bytes_received: u64,
bootstrap_rgba_bytes: u64,
steady_state_rgba_bytes: u64,
}
fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result<Child, Box<dyn Error>> {
@@ -241,23 +242,22 @@ fn drive_bench(
let mut summaries = Vec::new();
let mut surface_handles = Vec::new();
let mut current_surface_ids = Vec::new();
let mut rgba_bytes_received: u64 = 0;
let mut bootstrap_rgba_bytes: u64 = 0;
let mut steady_state_rgba_bytes: u64 = 0;
let navigate = build_ensure(tab, profile_id, url, 0, 0, false);
write_request(stdin, &navigate)?;
let response = read_response(reader, RESPONSE_TIMEOUT)?;
assert_frame_viewport_report(&response);
record_summary(&response, kind, &mut summaries);
record_surface_handle(&response, kind, &mut surface_handles);
record_current_surface_id(&response, &mut current_surface_ids);
rgba_bytes_received += response.frame.as_ref().map_or(0, |f| f.rgba_byte_count as u64);
record_rgba_bytes(&response, &mut bootstrap_rgba_bytes, &mut steady_state_rgba_bytes);
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
};
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)?;
@@ -265,14 +265,56 @@ fn drive_bench(
if let Some(error) = response.error.as_ref() {
return Err(format!("sidecar error at frame {frame_index}: {error}").into());
}
assert_frame_viewport_report(&response);
record_summary(&response, kind, &mut summaries);
record_surface_handle(&response, kind, &mut surface_handles);
record_current_surface_id(&response, &mut current_surface_ids);
rgba_bytes_received += response.frame.as_ref().map_or(0, |f| f.rgba_byte_count as u64);
record_rgba_bytes(&response, &mut bootstrap_rgba_bytes, &mut steady_state_rgba_bytes);
}
let _ = accumulated_scroll;
Ok(BenchOutcome { summaries, surface_handles, current_surface_ids, rgba_bytes_received })
Ok(BenchOutcome {
summaries,
surface_handles,
current_surface_ids,
bootstrap_rgba_bytes,
steady_state_rgba_bytes,
})
}
fn record_rgba_bytes(
response: &LiveResponse,
bootstrap_rgba_bytes: &mut u64,
steady_state_rgba_bytes: &mut u64,
) {
let rgba_byte_count = response.frame.as_ref().map_or(0, |frame| frame.rgba_byte_count as u64);
if response.current_surface_id.is_some() {
*steady_state_rgba_bytes += rgba_byte_count;
} else {
*bootstrap_rgba_bytes += rgba_byte_count;
}
}
fn assert_frame_viewport_report(response: &LiveResponse) {
let Some(frame) = response.frame.as_ref() else {
return;
};
let dpr = if frame.device_pixel_ratio.is_finite() && frame.device_pixel_ratio > 0.0 {
frame.device_pixel_ratio
} else {
1.0
};
let expected_width = ((frame.width as f32) / dpr).round().max(1.0) as u32;
let expected_height = ((frame.height as f32) / dpr).round().max(1.0) as u32;
assert_eq!(
frame.css_viewport_width, expected_width,
"CSS viewport width must match physical width divided by DPR",
);
assert_eq!(
frame.css_viewport_height, expected_height,
"CSS viewport height must match physical height divided by DPR",
);
}
fn record_surface_handle(
@@ -314,9 +356,7 @@ fn print_current_surface_summary(kind: &str, current_surface_ids: &[u64]) {
for id in current_surface_ids {
*counts.entry(*id).or_default() += 1;
}
eprintln!(
"\n=== ELY_PERF_KIND={kind} current_surface_id histogram (per-frame selector) ===",
);
eprintln!("\n=== ELY_PERF_KIND={kind} current_surface_id histogram (per-frame selector) ===",);
for (surface_id, count) in counts.iter() {
eprintln!("surface_id=0x{:x} frames={}", surface_id, count);
}
@@ -332,11 +372,7 @@ fn build_ensure(
) -> String {
let hover_x = if include_hover { Some(256u32) } else { None };
let hover_y = if include_hover { Some(256u32) } else { None };
let scroll_point = if scroll_dx != 0 || scroll_dy != 0 {
Some((256u32, 256u32))
} else {
None
};
let scroll_point = if scroll_dx != 0 || scroll_dy != 0 { Some((256u32, 256u32)) } else { None };
let hover_x_json = match hover_x {
Some(value) => format!("{value}"),
None => "null".to_string(),
@@ -417,17 +453,22 @@ fn read_response_with_bytes(
fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec<FramePerfSummary>) {
if let Some(perf) = response.perf.as_ref() {
assert_eq!(
perf.context, kind,
"sidecar context label must match requested kind"
);
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,
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());
}
@@ -438,19 +479,35 @@ fn print_summaries(kind: &str, frames: u32, summaries: &[FramePerfSummary]) {
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",
"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,
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,
);
}
}
@@ -586,11 +643,7 @@ fn drive_solid_color_render(
let report = report.ok_or("never received a frame with bytes")?;
let width = report.width as usize;
let height = report.height as usize;
assert_eq!(
bytes.len(),
width * height * 4,
"rgba byte count must match width × height × 4",
);
assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width × height × 4",);
// Sample 9 evenly-spaced points in the inner quartile of the
// viewport. Solid backgrounds should pass every sample; if Servo
+158 -3
View File
@@ -10,9 +10,10 @@ use std::{
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest, ScrollRequest,
ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState,
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest,
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest,
WebViewState,
};
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
@@ -25,6 +26,7 @@ const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
];
const SOFTWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_SOFTWARE_HOST_CHILD";
const DPR_VIEWPORT_CHILD_ENV: &str = "ELY_SERVO_DPR_VIEWPORT_CHILD";
const CLICK_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E";
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
@@ -36,6 +38,13 @@ struct PrdSiteCompatibilityCase {
title_fragment: &'static str,
}
struct DprViewportCase {
physical_width: u32,
physical_height: u32,
dpr: f32,
expected_css_width: u32,
}
#[test]
fn manages_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
if env::var_os(SOFTWARE_HOST_CHILD_ENV).is_none() {
@@ -67,6 +76,122 @@ fn run_isolated_software_host_lifecycle() -> Result<(), Box<dyn Error>> {
.into())
}
#[test]
fn first_navigation_preserves_dpr_for_css_viewport() -> Result<(), Box<dyn Error>> {
if env::var_os(DPR_VIEWPORT_CHILD_ENV).is_none() {
return run_isolated_dpr_viewport_test();
}
exercise_dpr_viewport_cases()
}
fn run_isolated_dpr_viewport_test() -> Result<(), Box<dyn Error>> {
let output = Command::new(env::current_exe()?)
.arg("--exact")
.arg("first_navigation_preserves_dpr_for_css_viewport")
.env(DPR_VIEWPORT_CHILD_ENV, "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
if output.status.success() {
return Ok(());
}
Err(format!(
"isolated DPR viewport test failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into())
}
fn exercise_dpr_viewport_cases() -> Result<(), Box<dyn Error>> {
let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(1, 1))?;
let profile_id = ProfileId::new();
let cases = [
DprViewportCase {
physical_width: 800,
physical_height: 600,
dpr: 1.0,
expected_css_width: 800,
},
DprViewportCase {
physical_width: 960,
physical_height: 640,
dpr: 2.0,
expected_css_width: 480,
},
DprViewportCase {
physical_width: 1250,
physical_height: 800,
dpr: 1.25,
expected_css_width: 1000,
},
DprViewportCase {
physical_width: 1440,
physical_height: 900,
dpr: 1.5,
expected_css_width: 960,
},
DprViewportCase {
physical_width: 1750,
physical_height: 1000,
dpr: 1.75,
expected_css_width: 1000,
},
DprViewportCase {
physical_width: 1500,
physical_height: 900,
dpr: 2.5,
expected_css_width: 600,
},
DprViewportCase {
physical_width: 2160,
physical_height: 1440,
dpr: 3.0,
expected_css_width: 720,
},
];
for case in cases {
let tab_id = TabId::new();
let webview_id = host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
ServoSurfaceSize::new(case.physical_width, case.physical_height),
)?;
host.set_hidpi_scale(HidpiScaleRequest {
webview_id: webview_id.clone(),
scale_factor: case.dpr,
})?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: UrlText::parse(viewport_probe_url(case.expected_css_width + 1))?,
})?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
let frame = host.last_rendered_frame()?;
assert_eq!(frame.width(), case.physical_width, "DPR case frame width");
assert_eq!(frame.height(), case.physical_height, "DPR case frame height");
assert_eq!(
center_pixel_rgb(&frame),
[238, 32, 77],
"CSS viewport must equal physical width divided by DPR: physical={} dpr={} expected_css={}",
case.physical_width,
case.dpr,
case.expected_css_width,
);
host.close_webview(&webview_id);
}
Ok(())
}
fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(INITIAL_WIDTH, INITIAL_HEIGHT))?;
let tab_id = TabId::new();
@@ -340,3 +465,33 @@ fn assert_frame_has_dimensions_and_content(
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
}
fn center_pixel_rgb(frame: &ely_servo_host::RenderedFrame) -> [u8; 3] {
let x = frame.width() / 2;
let y = frame.height() / 2;
let index = ((y * frame.width() + x) * 4) as usize;
let rgba = &frame.rgba_bytes()[index..index + 4];
[rgba[0], rgba[1], rgba[2]]
}
fn viewport_probe_url(min_width_threshold: u32) -> String {
let html = format!(
"<!doctype html><title>DPR Probe</title><style>\
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
</style>",
);
format!("data:text/html,{}", percent_encode_for_data_url(&html))
}
fn percent_encode_for_data_url(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}