From 07b9c9da01dc77eab477ef95cbbabe63e8feddee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 10 May 2026 23:17:05 -0400 Subject: [PATCH] T10.3: publish IOSurfaceHandle once per surface, current_surface_id per frame --- crates/ely_app/src/services/servo_live.rs | 63 ++++++++++ .../src/bin/ely_servo_sidecar/live.rs | 67 +++++++++- .../bin/ely_servo_sidecar/live_protocol.rs | 42 ++++++- .../src/hardware_rendering_context.rs | 39 +++--- crates/ely_servo_host/src/iosurface_handle.rs | 41 +++++++ crates/ely_servo_host/src/lib.rs | 4 +- crates/ely_servo_host/src/runtime.rs | 107 +++++++++++++--- crates/ely_servo_host/src/runtime_webview.rs | 6 + .../ely_servo_host/tests/live_perf_bench.rs | 116 +++++++++++++++++- 9 files changed, 431 insertions(+), 54 deletions(-) create mode 100644 crates/ely_servo_host/src/iosurface_handle.rs diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 61bb500..49c1e96 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -99,6 +99,13 @@ impl ServoLiveClient { log_frame_perf(perf); } + if let Some(handle) = response.surface_handle.as_ref() { + log_iosurface_handle(handle); + } + if let Some(surface_id) = response.current_surface_id { + log_iosurface_current(surface_id); + } + let Some(report) = response.frame else { return Ok(None); }; @@ -338,6 +345,33 @@ struct LiveResponse { frame: Option, #[serde(default)] perf: Option, + /// Hardware path only: present on the first frame after a new + /// IOSurface is bound (initial paint, resize, surfman swap chain + /// rotation). T10.4 will turn this into an imported Metal texture; + /// for now we log it on the `ely::servo::iosurface` target so the + /// pipeline is observable end-to-end without yet wiring it into + /// the renderer. + #[serde(default)] + surface_handle: Option, + /// Hardware path only: which previously-imported IOSurface to + /// sample this frame. surfman's attached swap chain rotates the + /// bound surface, so this id alternates between the values the + /// receiver has already imported via `surface_handle`. + #[serde(default)] + current_surface_id: Option, +} + +/// Wire mirror of `ely_servo_host::IOSurfaceHandle`. Duplicated rather +/// than imported because `ely_app` only talks to the sidecar via +/// stdin/stdout JSON — it has no crate dependency on `ely_servo_host` +/// and adding one just to share a four-field struct would pull the +/// Servo dep tree into the renderer process. +#[derive(Clone, Copy, Debug, Deserialize)] +struct LiveSurfaceHandle { + mach_port_name: u32, + surface_id: u64, + width: u32, + height: u32, } /// Aggregated frame-stage timings rolled up every N frames by the @@ -362,6 +396,35 @@ struct LiveFramePerfSummary { total_p99_us: u64, } +/// Per-frame tag that tells the renderer which already-imported +/// `MTLTexture` to sample. Emitted at `trace` instead of `info` because +/// it fires every frame on the hardware path; the import event above +/// is the rare `info` and this trace is the steady-state breadcrumb. +fn log_iosurface_current(surface_id: u64) { + tracing::trace!( + target: "ely::servo::iosurface", + surface_id, + "iosurface_current", + ); +} + +/// Emit one structured `tracing` event per IOSurface handover, on a +/// dedicated target so `RUST_LOG=ely::servo::iosurface=info` lights up +/// the cross-process surface pipeline without pulling in everything +/// else. The renderer (T10.4) will turn the same handle into an +/// imported Metal texture; today the event is the observable contract +/// that T10.3 plumbing is alive. +fn log_iosurface_handle(handle: &LiveSurfaceHandle) { + tracing::info!( + target: "ely::servo::iosurface", + mach_port_name = handle.mach_port_name, + surface_id = handle.surface_id, + width = handle.width, + height = handle.height, + "iosurface_handle", + ); +} + /// Emit one structured `tracing` event per perf summary, on a /// dedicated target so `RUST_LOG=ely::servo::perf=info` flips the /// stream on without dragging the rest of the app along. Filtering diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs index 44f77d9..0171c53 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, fs, io::{self, BufRead, Write}, thread, @@ -38,6 +38,7 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { let mut perf = FramePerfAggregator::new(context_label, FramePerfAggregator::DEFAULT_WINDOW_SIZE); let mut pending_summary: Option = None; + let mut published_surface_ids: HashMap> = HashMap::new(); let stdin = io::stdin(); let mut stdout = io::stdout().lock(); @@ -54,7 +55,9 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { // `write_outcome`. let frame_started_at = Instant::now(); let outcome = match serde_json::from_str::(&line) { - Ok(request) => handle_request(&mut host, &mut sessions, request), + Ok(request) => { + handle_request(&mut host, &mut sessions, &mut published_surface_ids, request) + } Err(error) => Err(LiveSidecarError::Json(error)), }; write_outcome( @@ -79,6 +82,7 @@ const fn rendering_context_label(kind: RenderingContextKind) -> &'static str { fn handle_request( host: &mut SoftwareServoHost, sessions: &mut HashMap, + published_surface_ids: &mut HashMap>, request: LiveRequest, ) -> Result { match request { @@ -101,7 +105,8 @@ fn handle_request( let tab = TabId::parse(tab_id.clone())?; let profile = ProfileId::parse(profile_id)?; let url = UrlText::parse(url)?; - let session = ensure_session(host, sessions, tab_id, &tab, &profile, width, height)?; + let session = + ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?; if apply_layout(host, session, width, height, page_zoom_percent)? { session.awaiting_visible_frame = true; @@ -131,17 +136,69 @@ fn handle_request( )? { session.awaiting_visible_frame = true; } - poll_frame(host, session) + let webview_id = session.webview_id.clone(); + let mut outcome = poll_frame(host, session)?; + populate_surface_fields(host, &webview_id, &tab_id, published_surface_ids, &mut outcome); + Ok(outcome) } LiveRequest::Poll { tab_id } => { let Some(session) = sessions.get_mut(&tab_id) else { return Ok(LiveOutcome::empty()); }; - poll_frame(host, session) + let webview_id = session.webview_id.clone(); + let mut outcome = poll_frame(host, session)?; + populate_surface_fields(host, &webview_id, &tab_id, published_surface_ids, &mut outcome); + Ok(outcome) } } } +/// Populate the hardware surface protocol fields on `outcome`. Two +/// pieces of state ride out together: +/// +/// * `current_surface_id` — set on every payload-bearing hardware +/// frame so the receiver knows which previously-imported +/// `MTLTexture` to sample THIS frame. surfman's attached swap +/// chain rotates front/back surfaces, so this alternates between +/// a small set of ids. +/// * `surface_handle` — populated only the first time the sidecar +/// sees a given `surface_id`; the receiver imports the IOSurface +/// once and caches the resulting Metal texture. Minting a fresh +/// mach port per frame would leak ports — `IOSurfaceCreateMachPort` +/// hands out a new send right each call and they don't free +/// automatically until the receiver `mach_port_deallocate`s. +fn populate_surface_fields( + host: &SoftwareServoHost, + webview_id: &ely_domain::WebViewId, + tab_id: &str, + published_surface_ids: &mut HashMap>, + outcome: &mut LiveOutcome, +) { + if outcome.frame.is_none() { + return; + } + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + { + let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else { + return; + }; + outcome.response.current_surface_id = Some(identity.surface_id); + let seen = published_surface_ids.entry(tab_id.to_string()).or_default(); + if seen.contains(&identity.surface_id) { + return; + } + let Ok(Some(handle)) = host.current_iosurface_handle(webview_id) else { + return; + }; + seen.insert(handle.surface_id); + outcome.response.surface_handle = Some(handle); + } + #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] + { + let _ = (host, webview_id, tab_id, published_surface_ids); + } +} + /// Serialise the response then stream the optional raw RGBA frame on /// the same stdout pipe. The client reads the JSON line, takes /// `rgba_byte_count` from the report, then reads that many bytes diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs index c5e9e6b..64632c3 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs @@ -3,7 +3,7 @@ use std::io; -use ely_servo_host::{RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState}; +use ely_servo_host::{IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -89,19 +89,53 @@ pub(super) struct LiveResponse { pub frame: Option, #[serde(skip_serializing_if = "Option::is_none")] pub perf: Option, + /// Populated on the first frame the sidecar emits for a given + /// surface — initial paint, after a resize, or whenever surfman + /// rotates its swap chain to a surface we haven't seen yet. The + /// receiver imports the IOSurface (via + /// `IOSurfaceLookupFromMachPort`) once per `surface_id` and caches + /// the resulting Metal texture. Always `None` on the software + /// path. + #[serde(skip_serializing_if = "Option::is_none")] + pub surface_handle: Option, + /// Populated on every hardware paint frame. Tells the receiver + /// which previously-imported IOSurface to sample THIS frame. The + /// surfman attached swap chain rotates between front/back + /// surfaces, so this id alternates between the values the receiver + /// has already imported. Always `None` on the software path. + #[serde(skip_serializing_if = "Option::is_none")] + pub current_surface_id: Option, } impl LiveResponse { fn empty() -> Self { - Self { error: None, frame: None, perf: None } + Self { + error: None, + frame: None, + perf: None, + surface_handle: None, + current_surface_id: None, + } } fn frame(frame: LiveFrameReport) -> Self { - Self { error: None, frame: Some(frame), perf: None } + Self { + error: None, + frame: Some(frame), + perf: None, + surface_handle: None, + current_surface_id: None, + } } fn error(message: String) -> Self { - Self { error: Some(message), frame: None, perf: None } + Self { + error: Some(message), + frame: None, + perf: None, + surface_handle: None, + current_surface_id: None, + } } } diff --git a/crates/ely_servo_host/src/hardware_rendering_context.rs b/crates/ely_servo_host/src/hardware_rendering_context.rs index cb6420b..ede1263 100644 --- a/crates/ely_servo_host/src/hardware_rendering_context.rs +++ b/crates/ely_servo_host/src/hardware_rendering_context.rs @@ -147,28 +147,32 @@ impl RenderingContext for HardwareOffscreenContext { } } -/// Cross-process handle to a hardware surface: the receiving process -/// can rebuild an `IOSurfaceRef` from `mach_port_name` and import it as -/// a Metal texture without ever copying the pixels. -/// -/// `width`/`height` are reported in surface pixels (post-DPR), matching -/// what surfman handed out at construction time. The receiver should -/// scale layout coordinates by its own backing scale factor. #[cfg(target_os = "macos")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct IOSurfaceHandle { - pub mach_port_name: u32, - pub width: u32, - pub height: u32, -} +use crate::iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; #[cfg(target_os = "macos")] impl HardwareOffscreenContext { + /// Cheap, non-mutating identity probe of the currently bound + /// surface. Reads `Device::context_surface_info` (no unbind, no + /// mach port creation) so callers can dedup before paying the + /// price of `current_iosurface_mach_port`. + pub fn peek_iosurface_identity(&self) -> Result { + let device = self.inner.device.borrow(); + let context = self.inner.context.borrow(); + let info = device.context_surface_info(&context)?.ok_or(SurfmanError::Failed)?; + Ok(IOSurfaceIdentity { + surface_id: info.id.0 as u64, + width: u32::try_from(info.size.width).unwrap_or(0), + height: u32::try_from(info.size.height).unwrap_or(0), + }) + } + /// Snapshot the IOSurface currently bound to the context and - /// return its mach port name plus dimensions. Increments the - /// IOSurface's mach-port use count; the receiving process holds it - /// via `IOSurfaceLookupFromMachPort` and is responsible for - /// `mach_port_deallocate` once the import is finished. + /// return its mach port name plus dimensions and stable surface + /// id. Increments the IOSurface's mach-port use count; the + /// receiving process holds it via `IOSurfaceLookupFromMachPort` and + /// is responsible for `mach_port_deallocate` once the import is + /// finished. /// /// Implementation note: surfman's CGL backend keeps the bound /// surface inside the GL context. To inspect it we temporarily @@ -189,6 +193,7 @@ impl HardwareOffscreenContext { let info = device.surface_info(&surface); let handle = IOSurfaceHandle { mach_port_name: mach_port, + surface_id: info.id.0 as u64, width: u32::try_from(info.size.width).unwrap_or(0), height: u32::try_from(info.size.height).unwrap_or(0), }; diff --git a/crates/ely_servo_host/src/iosurface_handle.rs b/crates/ely_servo_host/src/iosurface_handle.rs new file mode 100644 index 0000000..35bb79d --- /dev/null +++ b/crates/ely_servo_host/src/iosurface_handle.rs @@ -0,0 +1,41 @@ +//! Cross-process IOSurface descriptor types. +//! +//! These wire types live outside `hardware_rendering_context` (which +//! is hardware-render + macOS gated) so the sidecar's JSON protocol +//! can carry an `Option` regardless of feature +//! flags. The receiver always knows how to parse the field; if no +//! sender ever populates it (software-only build), it's just `None` +//! on every frame. +//! +//! Minting an [`IOSurfaceHandle`] requires a hardware surfman context +//! and a macOS host. That part lives in +//! [`crate::hardware_rendering_context`]. + +/// Cross-process handle to a hardware surface: the receiving process +/// rebuilds an `IOSurfaceRef` from `mach_port_name` and imports it as +/// a Metal texture without copying pixels. +/// +/// `surface_id` is the stable surfman `SurfaceID` (a pointer-shaped +/// `usize` widened to `u64` for the wire). It lets the receiver dedup: +/// when two consecutive frames carry the same `surface_id` the +/// imported `MTLTexture` is reused without re-importing. `width` and +/// `height` are reported in surface pixels (post-DPR). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "servo-engine", derive(serde::Serialize, serde::Deserialize))] +pub struct IOSurfaceHandle { + pub mach_port_name: u32, + pub surface_id: u64, + pub width: u32, + pub height: u32, +} + +/// Identity-only peek of the currently bound IOSurface. Distinguishes +/// "same surface as last frame" from "resize/swap rotated to a new +/// surface" without minting a fresh mach port (mach ports are a scarce +/// kernel resource and `IOSurfaceCreateMachPort` is not cheap). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IOSurfaceIdentity { + pub surface_id: u64, + pub width: u32, + pub height: u32, +} diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index 5199ad2..51e5176 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -2,6 +2,7 @@ mod error; #[cfg(feature = "hardware-render")] mod hardware_rendering_context; mod host; +mod iosurface_handle; #[cfg(feature = "servo-engine")] mod keyboard; #[cfg(feature = "servo-engine")] @@ -18,8 +19,7 @@ mod runtime_webview; pub use error::ServoHostError; #[cfg(feature = "hardware-render")] pub use hardware_rendering_context::HardwareOffscreenContext; -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -pub use hardware_rendering_context::IOSurfaceHandle; +pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; pub use host::{ KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index b047cc2..1f893fb 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -70,6 +70,17 @@ pub enum RenderingContextKind { Hardware, } +/// Pair of rendering-context handles produced by +/// [`SoftwareServoHost::new_rendering_context`]. The trait-object +/// handle drives Servo's compositor; the concrete hardware handle is +/// kept on the side so the host can call macOS-specific methods +/// (IOSurface mach port extraction) without downcasting. +struct RenderingContextHandles { + rendering_context: Rc, + #[cfg(feature = "hardware-render")] + hardware_context: Option>, +} + pub struct SoftwareServoHost { servo: Servo, default_surface_size: ServoSurfaceSize, @@ -377,10 +388,10 @@ impl SoftwareServoHost { size: ServoSurfaceSize, ) -> Result { let webview_id = WebViewId::new(); - let rendering_context = self.new_rendering_context(size)?; + let handles = self.new_rendering_context(size)?; let delegate = Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone())); - let webview = WebViewBuilder::new(&self.servo, rendering_context.clone()) + let webview = WebViewBuilder::new(&self.servo, handles.rendering_context.clone()) .delegate(delegate.clone()) .build(); // Cosmetic: makes the first frame paint into the rendering @@ -396,7 +407,9 @@ impl SoftwareServoHost { HostWebView { tab_id, profile_id, - rendering_context, + rendering_context: handles.rendering_context, + #[cfg(feature = "hardware-render")] + hardware_context: handles.hardware_context, webview, delegate, requested_url: None, @@ -406,27 +419,81 @@ impl SoftwareServoHost { Ok(webview_id) } + /// Cheap peek at the IOSurface identity bound to this webview's + /// hardware context. Returns `None` for software webviews and on + /// non-macOS hosts; otherwise the surfman `SurfaceID`-derived + /// identity plus dimensions. Used by the sidecar's live loop to + /// dedup mach port creation. + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + pub fn peek_iosurface_identity( + &self, + webview_id: &WebViewId, + ) -> Result, ServoHostError> { + let webview = self.webview(webview_id)?; + let Some(hardware) = webview.hardware_context.as_ref() else { + return Ok(None); + }; + hardware + .peek_iosurface_identity() + .map(Some) + .map_err(|_| ServoHostError::RenderingContextUnavailable) + } + + /// Mint a fresh mach port for the IOSurface bound to this + /// webview's hardware context. The caller is responsible for + /// transferring the port to the receiving process; if no transfer + /// happens, the port leaks. Software webviews return `None`. + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + pub fn current_iosurface_handle( + &self, + webview_id: &WebViewId, + ) -> Result, ServoHostError> { + let webview = self.webview(webview_id)?; + let Some(hardware) = webview.hardware_context.as_ref() else { + return Ok(None); + }; + hardware + .current_iosurface_mach_port() + .map(Some) + .map_err(|_| ServoHostError::RenderingContextUnavailable) + } + fn new_rendering_context( &self, size: ServoSurfaceSize, - ) -> Result, ServoHostError> { - let rendering_context: Rc = match self.rendering_context_kind { - RenderingContextKind::Software => Rc::new( - servo::SoftwareRenderingContext::new(size.physical()) - .map_err(|_| ServoHostError::RenderingContextUnavailable)?, - ), - #[cfg(feature = "hardware-render")] - RenderingContextKind::Hardware => Rc::new( - crate::HardwareOffscreenContext::new(size.physical()) - .map_err(|_| ServoHostError::RenderingContextUnavailable)?, - ), - #[cfg(not(feature = "hardware-render"))] - RenderingContextKind::Hardware => { - return Err(ServoHostError::HardwareRenderUnavailable); + ) -> Result { + match self.rendering_context_kind { + RenderingContextKind::Software => { + let rendering_context = Rc::new( + servo::SoftwareRenderingContext::new(size.physical()) + .map_err(|_| ServoHostError::RenderingContextUnavailable)?, + ); + rendering_context + .make_current() + .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; + Ok(RenderingContextHandles { + rendering_context, + #[cfg(feature = "hardware-render")] + hardware_context: None, + }) } - }; - rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; - Ok(rendering_context) + #[cfg(feature = "hardware-render")] + RenderingContextKind::Hardware => { + let hardware = Rc::new( + crate::HardwareOffscreenContext::new(size.physical()) + .map_err(|_| ServoHostError::RenderingContextUnavailable)?, + ); + hardware + .make_current() + .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; + Ok(RenderingContextHandles { + rendering_context: hardware.clone(), + hardware_context: Some(hardware), + }) + } + #[cfg(not(feature = "hardware-render"))] + RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable), + } } fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> { diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs index c886ac5..9c94616 100644 --- a/crates/ely_servo_host/src/runtime_webview.rs +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -13,6 +13,12 @@ pub(super) struct HostWebView { pub(super) tab_id: TabId, pub(super) profile_id: ProfileId, pub(super) rendering_context: Rc, + /// Parallel concrete handle when the rendering context is the + /// vendored hardware path. `None` for software webviews. Lets the + /// host call macOS-specific methods (IOSurface mach port + /// extraction) without downcasting `dyn RenderingContext`. + #[cfg(feature = "hardware-render")] + pub(super) hardware_context: Option>, pub(super) webview: WebView, pub(super) delegate: Rc, pub(super) requested_url: Option, diff --git a/crates/ely_servo_host/tests/live_perf_bench.rs b/crates/ely_servo_host/tests/live_perf_bench.rs index e8e6b79..5907990 100644 --- a/crates/ely_servo_host/tests/live_perf_bench.rs +++ b/crates/ely_servo_host/tests/live_perf_bench.rs @@ -49,6 +49,18 @@ struct LiveResponse { frame: Option, #[serde(default)] perf: Option, + #[serde(default)] + surface_handle: Option, + #[serde(default)] + current_surface_id: Option, +} + +#[derive(Deserialize, Debug, Clone, Copy)] +struct BenchSurfaceHandle { + mach_port_name: u32, + surface_id: u64, + width: u32, + height: u32, } #[derive(Deserialize, Debug)] @@ -99,9 +111,9 @@ fn run_live_bench() -> Result<(), Box> { let stdout = child.stdout.take().ok_or("sidecar stdout missing")?; let mut reader = BufReader::new(stdout); - let summaries = + let outcome = match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames) { - Ok(summaries) => summaries, + Ok(outcome) => outcome, Err(error) => { drop(stdin); let _ = child.kill(); @@ -114,14 +126,53 @@ fn run_live_bench() -> Result<(), Box> { let _ = child.wait(); cleanup(&profile_data_dir)?; - print_summaries(&kind, frames, &summaries); + print_summaries(&kind, frames, &outcome.summaries); + print_surface_handles(&kind, &outcome.surface_handles); + print_current_surface_summary(&kind, &outcome.current_surface_ids); assert!( - !summaries.is_empty(), + !outcome.summaries.is_empty(), "expected at least one FramePerfSummary across {frames} frames" ); + if kind == "hardware" { + assert!( + !outcome.surface_handles.is_empty(), + "hardware path must publish at least one IOSurface handle" + ); + // Mach port dedup: the surfman attached swap chain on macOS + // rotates between front+back surfaces, so we expect a SMALL + // number of distinct mach port publishes — definitely not one + // per frame. Anything close to `frames` is a regression to the + // T10.3 starting state where dedup tracked only the last id. + let max_expected = 8; + assert!( + outcome.surface_handles.len() <= max_expected, + "expected at most {max_expected} IOSurface publishes (one per unique surface), \ + got {} — dedup regressed", + outcome.surface_handles.len() + ); + assert!( + !outcome.current_surface_ids.is_empty(), + "hardware path must report current_surface_id on every frame" + ); + } else { + assert!( + outcome.surface_handles.is_empty(), + "software path must never publish an IOSurface handle" + ); + assert!( + outcome.current_surface_ids.is_empty(), + "software path must never report current_surface_id" + ); + } Ok(()) } +struct BenchOutcome { + summaries: Vec, + surface_handles: Vec, + current_surface_ids: Vec, +} + fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result> { let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")); command @@ -144,13 +195,17 @@ fn drive_bench( profile_id: &ProfileId, url: &str, frames: u32, -) -> Result, Box> { +) -> Result> { let mut summaries = Vec::new(); + let mut surface_handles = Vec::new(); + let mut current_surface_ids = 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); + record_surface_handle(&response, kind, &mut surface_handles); + record_current_surface_id(&response, &mut current_surface_ids); let mut accumulated_scroll = 0; for frame_index in 0..frames { @@ -167,10 +222,59 @@ fn drive_bench( return Err(format!("sidecar error at frame {frame_index}: {error}").into()); } record_summary(&response, kind, &mut summaries); + record_surface_handle(&response, kind, &mut surface_handles); + record_current_surface_id(&response, &mut current_surface_ids); } let _ = accumulated_scroll; - Ok(summaries) + Ok(BenchOutcome { summaries, surface_handles, current_surface_ids }) +} + +fn record_surface_handle( + response: &LiveResponse, + kind: &str, + surface_handles: &mut Vec, +) { + if let Some(handle) = response.surface_handle { + eprintln!( + "[iosurface {kind}] new surface_id=0x{:x} mach_port=0x{:x} {}x{}", + handle.surface_id, handle.mach_port_name, handle.width, handle.height, + ); + surface_handles.push(handle); + } +} + +fn record_current_surface_id(response: &LiveResponse, current_surface_ids: &mut Vec) { + if let Some(surface_id) = response.current_surface_id { + current_surface_ids.push(surface_id); + } +} + +fn print_surface_handles(kind: &str, surface_handles: &[BenchSurfaceHandle]) { + eprintln!( + "\n=== ELY_PERF_KIND={kind} iosurface_imports={} (one per unique surface) ===", + surface_handles.len() + ); + for (index, handle) in surface_handles.iter().enumerate() { + eprintln!( + "{:<4} surface_id=0x{:x} mach_port=0x{:x} {}x{}", + index, handle.surface_id, handle.mach_port_name, handle.width, handle.height, + ); + } +} + +fn print_current_surface_summary(kind: &str, current_surface_ids: &[u64]) { + use std::collections::BTreeMap; + let mut counts: BTreeMap = BTreeMap::new(); + 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) ===", + ); + for (surface_id, count) in counts.iter() { + eprintln!("surface_id=0x{:x} frames={}", surface_id, count); + } } fn build_ensure(