fix(servo): bound live protocol metadata

This commit is contained in:
2026-07-09 23:32:17 -04:00
parent a6e3aeaf46
commit 83badaea81
13 changed files with 482 additions and 68 deletions
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, VecDeque},
fs::{self, File, OpenOptions, TryLockError},
io::{self, BufRead},
path::Path,
@@ -17,7 +17,7 @@ use super::{
live_output::write_outcome,
live_protocol::{
LIVE_PROTOCOL_VERSION, LiveFrameReport, LiveOutcome, LiveRequest, LiveSidecarError,
validated_frame_byte_count,
MAX_LOADED_URL_BYTES, MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE, validated_frame_byte_count,
},
live_session::{
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, bind_profile,
@@ -55,6 +55,7 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
let mut sessions = HashMap::new();
let mut active_profile = None;
let mut handshake_complete = false;
let mut pending_permission_consumptions = VecDeque::new();
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
@@ -78,9 +79,13 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
request,
)
});
let outcome = outcome
.map(|outcome| outcome.with_permission_consumptions(host.take_consumed_permissions()));
write_outcome(&mut stdout, outcome)?;
pending_permission_consumptions.extend(host.take_consumed_permissions());
let outcome = match outcome {
Ok(outcome) => outcome,
Err(error) => LiveOutcome::error(error.to_string()),
}
.with_permission_consumptions(take_permission_batch(&mut pending_permission_consumptions));
write_outcome(&mut stdout, Ok(outcome))?;
if should_shutdown {
break;
}
@@ -88,6 +93,11 @@ pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
Ok(())
}
fn take_permission_batch<T>(pending: &mut VecDeque<T>) -> Vec<T> {
let count = pending.len().min(MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE);
pending.drain(..count).collect()
}
fn acquire_profile_data_lease(profile_data_dir: &Path) -> Result<File, LiveSidecarError> {
let lease = OpenOptions::new()
.create(true)
@@ -110,7 +120,7 @@ fn handle_request(
active_profile: &mut Option<ProfileId>,
handshake_complete: &mut bool,
rendering_context_kind: RenderingContextKind,
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option<
#[cfg(all(feature = "hardware-render", target_os = "macos"))] mut hardware_transport: Option<
&mut HardwareSurfaceTransport,
>,
request: LiveRequest,
@@ -154,6 +164,9 @@ fn handle_request(
pending_surface_ids,
} => {
validated_frame_byte_count(width, height)?;
if url.len() > MAX_LOADED_URL_BYTES {
return Err(LiveSidecarError::RequestUrlTooLong { limit: MAX_LOADED_URL_BYTES });
}
let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?;
bind_profile(active_profile, &profile)?;
@@ -205,7 +218,16 @@ fn handle_request(
rendering_context_kind,
&ready_surface_ids,
&pending_surface_ids,
)?;
);
retire_oversized_loaded_url_session(
host,
sessions,
&tab_id,
&outcome,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
hardware_transport.as_deref_mut(),
);
let outcome = outcome?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
@@ -236,7 +258,16 @@ fn handle_request(
rendering_context_kind,
&ready_surface_ids,
&pending_surface_ids,
)?;
);
retire_oversized_loaded_url_session(
host,
sessions,
&tab_id,
&outcome,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
hardware_transport.as_deref_mut(),
);
let outcome = outcome?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
@@ -270,6 +301,27 @@ fn handle_request(
}
}
fn retire_oversized_loaded_url_session(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
tab_id: &str,
outcome: &Result<LiveOutcome, LiveSidecarError>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option<
&mut HardwareSurfaceTransport,
>,
) {
if !matches!(outcome, Err(LiveSidecarError::LoadedUrlTooLong { .. })) {
return;
}
if let Some(session) = sessions.remove(tab_id) {
host.close_webview(&session.webview_id);
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
if let Some(transport) = hardware_transport {
transport.close_tab(tab_id);
}
}
fn poll_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
@@ -303,7 +355,7 @@ fn poll_software_frame(
{
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let report =
LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false);
LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false)?;
return Ok(LiveOutcome::frame(report, frame));
}
return Ok(LiveOutcome::empty());
@@ -313,7 +365,7 @@ fn poll_software_frame(
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
session.last_frame = Some(frame.clone());
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true);
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true)?;
Ok(LiveOutcome::frame(report, frame))
}
@@ -358,7 +410,7 @@ fn poll_hardware_frame(
identity.height,
session.device_pixel_ratio(),
false,
);
)?;
return Ok(LiveOutcome::surface(report));
}
HardwarePollAction::Empty => return Ok(LiveOutcome::empty()),
@@ -380,7 +432,7 @@ fn poll_hardware_frame(
identity.height,
session.device_pixel_ratio(),
true,
);
)?;
Ok(LiveOutcome::surface(report))
}
@@ -414,35 +466,6 @@ fn hardware_poll_action(
}
}
#[cfg(all(test, feature = "hardware-render", target_os = "macos"))]
mod tests {
use super::{HardwarePollAction, hardware_poll_action};
#[test]
fn newly_ready_surface_replays_before_pending_frame() {
assert_eq!(
hardware_poll_action(true, false, true, false, true, true),
HardwarePollAction::ReplaySurface
);
assert_eq!(
hardware_poll_action(true, false, false, false, true, true),
HardwarePollAction::PaintFrame
);
}
#[test]
fn awaiting_ready_surface_backpressures_pending_frame() {
assert_eq!(
hardware_poll_action(true, true, false, false, true, false),
HardwarePollAction::Empty
);
}
#[test]
fn missing_surface_replays_before_first_ready() {
assert_eq!(
hardware_poll_action(true, false, false, true, true, false),
HardwarePollAction::ReplaySurface
);
}
}
#[cfg(test)]
#[path = "live_tests.rs"]
mod tests;
@@ -1,6 +1,12 @@
use std::io::Write;
use super::live_protocol::{LiveOutcome, LiveSidecarError, validated_frame_byte_count};
use super::live_protocol::{
LiveOutcome, LiveResponse, LiveSidecarError, MAX_ERROR_BYTES, MAX_RESPONSE_HEADER_BYTES,
MAX_TITLE_BYTES, validated_frame_byte_count,
};
const TITLE_TRUNCATION_SUFFIX: &str = "";
const ERROR_TRUNCATION_SUFFIX: &str = "… [truncated]";
pub(super) fn write_outcome(
stdout: &mut impl Write,
@@ -17,8 +23,17 @@ pub(super) fn write_outcome(
outcome = LiveOutcome::error(error.to_string());
outcome.response.permission_consumptions = consumptions;
}
bound_response_text(&mut outcome.response);
serde_json::to_writer(&mut *stdout, &outcome.response)?;
let header = serde_json::to_vec(&outcome.response)?;
let wire_bytes = header.len().saturating_add(1);
if wire_bytes > MAX_RESPONSE_HEADER_BYTES {
return Err(LiveSidecarError::ResponseHeaderTooLarge {
bytes: wire_bytes,
limit: MAX_RESPONSE_HEADER_BYTES,
});
}
stdout.write_all(&header)?;
stdout.write_all(b"\n")?;
if let Some(frame) = outcome.frame.as_ref() {
stdout.write_all(frame.rgba_bytes())?;
@@ -27,6 +42,33 @@ pub(super) fn write_outcome(
Ok(())
}
fn bound_response_text(response: &mut LiveResponse) {
response.error = response
.error
.take()
.map(|error| truncate_utf8(error, MAX_ERROR_BYTES, ERROR_TRUNCATION_SUFFIX));
if let Some(frame) = response.frame.as_mut() {
frame.title = frame
.title
.take()
.map(|title| truncate_utf8(title, MAX_TITLE_BYTES, TITLE_TRUNCATION_SUFFIX));
}
}
fn truncate_utf8(mut value: String, limit: usize, suffix: &str) -> String {
if value.len() <= limit {
return value;
}
let mut end = limit - suffix.len();
while !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
value.push_str(suffix);
value
}
fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSidecarError> {
let expected = validated_frame_byte_count(width, height)?;
if expected != actual {
@@ -37,8 +79,107 @@ fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSide
#[cfg(test)]
mod tests {
use super::super::live_protocol::{
LiveFrameReport, LivePermissionConsumption, MAX_LOADED_URL_BYTES,
MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE,
};
use super::*;
#[test]
fn overlong_utf8_title_stays_within_the_app_header_limit() -> Result<(), LiveSidecarError> {
let frame = one_pixel_frame();
let snapshot = snapshot(Some("https://example.com"), Some(&"\"\n".repeat(40_000)));
let report = LiveFrameReport::new(&snapshot, &frame, 1.0, true)?;
let mut output = Vec::new();
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?;
assert!(header_bytes(&output) <= MAX_RESPONSE_HEADER_BYTES);
Ok(())
}
#[test]
fn wire_text_preserves_exact_boundary_and_marks_ascii_overflow() {
let exact = "a".repeat(MAX_TITLE_BYTES);
assert_eq!(truncate_utf8(exact.clone(), MAX_TITLE_BYTES, TITLE_TRUNCATION_SUFFIX), exact);
let overflow = truncate_utf8(
"a".repeat(MAX_TITLE_BYTES + 1),
MAX_TITLE_BYTES,
TITLE_TRUNCATION_SUFFIX,
);
assert_eq!(overflow.len(), MAX_TITLE_BYTES);
assert!(overflow.ends_with(TITLE_TRUNCATION_SUFFIX));
}
#[test]
fn error_text_truncates_on_a_utf8_boundary() {
let mut response = LiveOutcome::error("".repeat(MAX_ERROR_BYTES)).response;
bound_response_text(&mut response);
let error = response.error.as_deref().unwrap_or_default();
assert!(error.len() <= MAX_ERROR_BYTES);
assert!(error.ends_with(ERROR_TRUNCATION_SUFFIX));
}
#[test]
fn escape_heavy_maximum_frame_and_permission_batch_fit_header() -> Result<(), LiveSidecarError>
{
let frame = one_pixel_frame();
let report = LiveFrameReport {
loaded_url: Some("\u{2}".repeat(MAX_LOADED_URL_BYTES)),
title: Some("\u{1}".repeat(MAX_TITLE_BYTES)),
state: "complete",
width: 1,
height: 1,
device_pixel_ratio: 1.0,
css_viewport_width: 1,
css_viewport_height: 1,
rgba_byte_count: 4,
pixels_changed: true,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
};
let mut outcome = LiveOutcome::frame(report, frame);
let profile_id = ely_domain::ProfileId::new().as_str().to_string();
outcome.response.permission_consumptions = (0..MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE)
.map(|revision| LivePermissionConsumption {
profile_id: profile_id.clone(),
origin: "\u{3}".repeat(ely_domain::MAX_SITE_ORIGIN_BYTES),
feature: "storage-persistence".to_string(),
grant_revision: revision as u64,
})
.collect();
let mut output = Vec::new();
write_outcome(&mut output, Ok(outcome))?;
assert!(header_bytes(&output) <= MAX_RESPONSE_HEADER_BYTES);
Ok(())
}
#[test]
fn loaded_url_preserves_exact_boundary_and_rejects_overflow() -> Result<(), LiveSidecarError> {
let prefix = "https://example.com/";
let exact_url = format!("{prefix}{}", "a".repeat(MAX_LOADED_URL_BYTES - prefix.len()));
assert!(url::Url::parse(&exact_url).is_ok());
let exact_snapshot = snapshot(Some(&exact_url), None);
let exact = LiveFrameReport::new(&exact_snapshot, &one_pixel_frame(), 1.0, true)?;
assert_eq!(exact.loaded_url.as_deref(), Some(exact_url.as_str()));
let overflow_url =
format!("{prefix}{}", "a".repeat(MAX_LOADED_URL_BYTES + 1 - prefix.len()));
assert!(url::Url::parse(&overflow_url).is_ok());
let overflow_snapshot = snapshot(Some(&overflow_url), None);
assert!(matches!(
LiveFrameReport::new(&overflow_snapshot, &one_pixel_frame(), 1.0, true),
Err(LiveSidecarError::LoadedUrlTooLong { limit: MAX_LOADED_URL_BYTES })
));
Ok(())
}
#[test]
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
let profile_id = ely_domain::ProfileId::new();
@@ -52,8 +193,7 @@ mod tests {
None,
ely_servo_host::WebViewSnapshotPending::new(false, false),
);
let report =
super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true);
let report = LiveFrameReport::new(&snapshot, &frame, 1.0, true)?;
let mut output = Vec::new();
let outcome = LiveOutcome::frame(report, frame).with_permission_consumptions(vec![
ely_servo_host::ConsumedPermission {
@@ -74,4 +214,24 @@ mod tests {
assert_eq!(response["permission_consumptions"][0]["grant_revision"], 7);
Ok(())
}
fn one_pixel_frame() -> ely_servo_host::RenderedFrame {
ely_servo_host::RenderedFrame::from_rgba_bytes(1, 1, vec![255; 4])
}
fn snapshot(url: Option<&str>, title: Option<&str>) -> ely_servo_host::WebViewSnapshot {
ely_servo_host::WebViewSnapshot::new(
ely_domain::WebViewId::new(),
ely_domain::TabId::new(),
ely_domain::ProfileId::new(),
ely_servo_host::WebViewState::Complete,
url.map(str::to_string),
title.map(str::to_string),
ely_servo_host::WebViewSnapshotPending::new(false, false),
)
}
fn header_bytes(output: &[u8]) -> usize {
output.iter().position(|byte| *byte == b'\n').map_or(0, |end| end + 1)
}
}
@@ -13,6 +13,11 @@ use super::iosurface_mach::IOSurfaceMachError;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 3;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
pub(super) const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
pub(super) const MAX_TITLE_BYTES: usize = ely_servo_host::MAX_PAGE_TITLE_BYTES;
pub(super) const MAX_LOADED_URL_BYTES: usize = 32 * 1024;
pub(super) const MAX_ERROR_BYTES: usize = 32 * 1024;
pub(super) const MAX_PERMISSION_CONSUMPTIONS_PER_RESPONSE: usize = 8;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -208,12 +213,12 @@ impl LiveFrameReport {
frame: &RenderedFrame,
device_pixel_ratio: f32,
pixels_changed: bool,
) -> Self {
) -> Result<Self, LiveSidecarError> {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
let css_viewport_width = css_dimension(frame.width(), device_pixel_ratio);
let css_viewport_height = css_dimension(frame.height(), device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
Ok(Self {
loaded_url: loaded_url_for_response(snapshot.url())?,
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width: frame.width(),
@@ -226,7 +231,7 @@ impl LiveFrameReport {
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(),
}
})
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
@@ -236,10 +241,10 @@ impl LiveFrameReport {
height: u32,
device_pixel_ratio: f32,
pixels_changed: bool,
) -> Self {
) -> Result<Self, LiveSidecarError> {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
Ok(Self {
loaded_url: loaded_url_for_response(snapshot.url())?,
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width,
@@ -252,7 +257,17 @@ impl LiveFrameReport {
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
})
}
}
fn loaded_url_for_response(value: Option<&str>) -> Result<Option<String>, LiveSidecarError> {
match value {
Some(url) if url.len() > MAX_LOADED_URL_BYTES => {
Err(LiveSidecarError::LoadedUrlTooLong { limit: MAX_LOADED_URL_BYTES })
}
Some(url) => Ok(Some(url.to_string())),
None => Ok(None),
}
}
@@ -282,6 +297,15 @@ pub(super) enum LiveSidecarError {
#[error("live protocol mismatch: expected {expected}, received {actual}")]
ProtocolVersionMismatch { expected: u32, actual: u32 },
#[error("request URL exceeds the {limit}-byte live protocol limit")]
RequestUrlTooLong { limit: usize },
#[error("loaded URL exceeds the {limit}-byte live protocol limit")]
LoadedUrlTooLong { limit: usize },
#[error("response header requires {bytes} bytes; the live protocol limit is {limit}")]
ResponseHeaderTooLarge { bytes: usize, limit: usize },
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error("hardware rendering requires --iosurface-mach-service")]
IOSurfaceMachServiceRequired,
@@ -0,0 +1,44 @@
use std::collections::VecDeque;
use super::take_permission_batch;
#[test]
fn nine_permission_consumptions_are_sent_in_order_across_two_responses() {
let mut pending = (1_u8..=9).collect::<VecDeque<_>>();
assert_eq!(take_permission_batch(&mut pending), (1_u8..=8).collect::<Vec<_>>());
assert_eq!(take_permission_batch(&mut pending), vec![9]);
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
mod hardware {
use super::super::{HardwarePollAction, hardware_poll_action};
#[test]
fn newly_ready_surface_replays_before_pending_frame() {
assert_eq!(
hardware_poll_action(true, false, true, false, true, true),
HardwarePollAction::ReplaySurface
);
assert_eq!(
hardware_poll_action(true, false, false, false, true, true),
HardwarePollAction::PaintFrame
);
}
#[test]
fn awaiting_ready_surface_backpressures_pending_frame() {
assert_eq!(
hardware_poll_action(true, true, false, false, true, false),
HardwarePollAction::Empty
);
}
#[test]
fn missing_surface_replays_before_first_ready() {
assert_eq!(
hardware_poll_action(true, false, false, true, true, false),
HardwarePollAction::ReplaySurface
);
}
}
+2
View File
@@ -4,6 +4,8 @@ use ely_domain::{
use crate::ServoHostError;
pub const MAX_PAGE_TITLE_BYTES: usize = 4 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WebViewState {
Created,
+5 -5
View File
@@ -20,11 +20,11 @@ pub use error::ServoHostError;
#[cfg(feature = "hardware-render")]
pub use hardware_rendering_context::HardwareOffscreenContext;
pub use host::{
ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest,
MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision,
PermissionSnapshotEntry, PermissionSnapshotRequest, PermissionSnapshotState, RenderedFrame,
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
WebViewSnapshot, WebViewSnapshotPending, WebViewState,
ConsumedPermission, HidpiScaleRequest, KeyboardTextRequest, MAX_PAGE_TITLE_BYTES,
MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest,
PermissionDecision, PermissionSnapshotEntry, PermissionSnapshotRequest,
PermissionSnapshotState, RenderedFrame, RenderedFrameSummary, ResizeRequest, ScrollRequest,
ServoHost, TouchTapRequest, WebViewSnapshot, WebViewSnapshotPending, WebViewState,
};
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
#[cfg(feature = "servo-engine")]
+28 -3
View File
@@ -5,7 +5,8 @@ use servo::{LoadStatus, RenderingContext, WebView, WebViewDelegate};
use url::Url;
use crate::{
PermissionDecision, WebViewSnapshot, WebViewSnapshotPending, WebViewState,
MAX_PAGE_TITLE_BYTES, PermissionDecision, WebViewSnapshot, WebViewSnapshotPending,
WebViewState,
runtime_permissions::{PermissionStore, permission_decision_for_webview},
};
@@ -56,7 +57,7 @@ impl HostWebView {
}
fn current_title(&self) -> Option<String> {
self.webview.page_title().or_else(|| self.delegate.title())
self.delegate.title().or_else(|| self.webview.page_title().map(bound_page_title))
}
}
@@ -105,7 +106,7 @@ impl HostWebViewDelegate {
}
fn record_title_change(&self, title: Option<String>) {
self.title.replace(title);
self.title.replace(title.map(bound_page_title));
self.has_pending_metadata.set(true);
}
@@ -139,6 +140,19 @@ impl HostWebViewDelegate {
}
}
fn bound_page_title(mut title: String) -> String {
if title.len() <= MAX_PAGE_TITLE_BYTES {
return title;
}
let mut end = MAX_PAGE_TITLE_BYTES - "".len();
while !title.is_char_boundary(end) {
end -= 1;
}
title.truncate(end);
title.push('…');
title
}
impl WebViewDelegate for HostWebViewDelegate {
fn notify_url_changed(&self, _webview: WebView, url: Url) {
self.record_url_change(url.to_string());
@@ -209,4 +223,15 @@ mod tests {
assert!(!delegate.has_pending_frame());
assert!(delegate.has_pending_metadata());
}
#[test]
fn snapshot_title_source_bounds_multibyte_metadata() {
let delegate = HostWebViewDelegate::new(ProfileId::new(), PermissionStore::default());
delegate.record_title_change(Some("".repeat(super::MAX_PAGE_TITLE_BYTES)));
let title = delegate.title().unwrap_or_default();
assert!(title.len() <= super::MAX_PAGE_TITLE_BYTES);
assert!(title.ends_with('…'));
}
}