feat(servo): isolate profiles with hardware sidecars

This commit is contained in:
2026-07-09 20:27:22 -04:00
parent 78dc86b18e
commit c28ec2bee8
92 changed files with 10306 additions and 1485 deletions
+203 -86
View File
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, VecDeque},
io,
sync::{Arc, Condvar, Mutex, mpsc},
thread::JoinHandle,
@@ -9,11 +9,10 @@ use crate::services::servo_live::{
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
};
/// Blocking surface for the embedded Servo runtime.
/// Blocking transport for one profile-scoped Servo sidecar.
///
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
/// fake. The contract: every call is blocking and may run for tens of
/// milliseconds. Implementations live on the worker thread.
/// Production wraps [`ServoLiveClient`]; tests substitute a fake. Every
/// call may block on sidecar IPC, so implementations live on a worker.
pub(super) trait LiveRuntimeClient {
fn ensure(
&mut self,
@@ -57,7 +56,7 @@ impl LiveRuntimeClientError {
impl std::fmt::Display for LiveRuntimeClientError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
Self::RuntimeUnavailable => formatter.write_str("servo sidecar runtime is unavailable"),
Self::Message(message) => formatter.write_str(message),
}
}
@@ -86,41 +85,57 @@ impl From<io::Error> for LiveRuntimeClientError {
/// Output of a worker request.
pub(super) enum WorkerResponse {
Frame { tab_id: String, frame: ServoLiveFrame },
Failed { tab_id: String, message: String },
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
Failed { generation: RequestGeneration, tab_id: String, message: String },
RuntimeUnavailable,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct RequestGeneration(u64);
impl RequestGeneration {
pub(super) const fn new(value: u64) -> Self {
Self(value)
}
}
enum WorkerRequest {
Ensure(ServoLiveEnsureRequest),
Poll { tab_id: String },
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest },
Poll { generation: RequestGeneration, tab_id: String },
}
impl WorkerRequest {
fn tab_id(&self) -> &str {
match self {
Self::Ensure { request, .. } => request.tab_id.as_str(),
Self::Poll { tab_id, .. } => tab_id.as_str(),
}
}
fn failure_parts(self) -> (RequestGeneration, String) {
match self {
Self::Ensure { generation, request } => (generation, request.tab_id),
Self::Poll { generation, tab_id } => (generation, tab_id),
}
}
}
struct WorkerQueue {
/// Latest request per tab. A new submission for a tab replaces any
/// earlier in-flight-but-not-yet-started request, so a flurry of
/// scrolls never piles up — the worker always processes the most
/// recent frame's worth of inputs.
pending: BTreeMap<String, WorkerRequest>,
/// Close orders. Sent after pending is cleared for that tab so the
/// worker never closes a tab that still has live frames in flight.
closes: Vec<String>,
/// True while the worker is processing a request. `wait_until_idle`
/// uses this alongside the queue emptiness to know when all
/// previously-submitted work has actually run.
/// Ordered inputs queue; idle and hover updates coalesce at the tail.
pending: BTreeMap<String, VecDeque<WorkerRequest>>,
/// Round-robin tab order, with each pending tab represented once.
ready_tabs: VecDeque<String>,
closes: VecDeque<String>,
in_flight: bool,
in_flight_tab: Option<String>,
initialization_failure: Option<String>,
shutdown: bool,
}
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
/// a non-blocking API: submit ensure/poll/close, then drain responses.
///
/// The UI thread never blocks on Servo. Submissions push into a
/// coalescing queue (latest request per tab wins). The worker thread
/// drains the queue, runs the blocking calls, and emits responses on a
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
/// Runs one blocking profile client behind a non-blocking fair queue.
pub(super) struct LiveRuntimeWorker {
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
response_rx: mpsc::Receiver<WorkerResponse>,
thread: Option<JoinHandle<()>>,
}
@@ -132,46 +147,35 @@ impl LiveRuntimeWorker {
let queue = Arc::new((
Mutex::new(WorkerQueue {
pending: BTreeMap::new(),
closes: Vec::new(),
ready_tabs: VecDeque::new(),
closes: VecDeque::new(),
in_flight: false,
in_flight_tab: None,
initialization_failure: None,
shutdown: false,
}),
Condvar::new(),
));
let (response_tx, response_rx) = mpsc::channel();
let (init_tx, init_rx) = mpsc::channel();
let queue_for_thread = queue.clone();
let response_for_thread = response_tx.clone();
let thread = std::thread::Builder::new()
.name("ely-servo-runtime".to_string())
.spawn(move || {
let client = match client_factory() {
Ok(client) => {
let _ = init_tx.send(Ok(()));
client
}
Err(error) => {
let _ = init_tx.send(Err(error));
return;
}
};
run_worker(client, queue_for_thread, response_tx);
.spawn(move || match client_factory() {
Ok(client) => run_worker(client, queue_for_thread, response_for_thread),
Err(error) => {
fail_worker_initialization(queue_for_thread, &response_for_thread, error);
}
})
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
match init_rx.recv() {
Ok(Ok(())) => {}
Ok(Err(error)) => {
let _ = thread.join();
return Err(error);
}
Err(error) => {
let _ = thread.join();
return Err(format!("servo live worker initialization failed: {error}"));
}
}
Ok(Self { queue, response_rx, thread: Some(thread) })
Ok(Self { queue, response_tx, response_rx, thread: Some(thread) })
}
pub(super) fn submit_ensure(&self, request: ServoLiveEnsureRequest) {
pub(super) fn submit_ensure(
&self,
generation: RequestGeneration,
request: ServoLiveEnsureRequest,
) {
let tab_id = request.tab_id.clone();
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
@@ -181,11 +185,31 @@ impl LiveRuntimeWorker {
if q.shutdown {
return;
}
q.pending.insert(tab_id, WorkerRequest::Ensure(request));
if let Some(message) = q.initialization_failure.clone() {
drop(q);
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return;
}
let mut request = WorkerRequest::Ensure { generation, request };
if let Some(pending) = q.pending.get_mut(&tab_id) {
let replace_tail = pending.back().is_some_and(|tail| {
matches!(tail, WorkerRequest::Poll { .. })
|| (!request_has_ordered_input(&request) && !request_has_ordered_input(tail))
});
if replace_tail && let Some(tail) = pending.back_mut() {
preserve_latest_hover(&mut request, tail);
*tail = request;
} else {
pending.push_back(request);
}
} else {
q.pending.insert(tab_id.clone(), VecDeque::from([request]));
q.ready_tabs.push_back(tab_id);
}
cvar.notify_one();
}
pub(super) fn submit_poll(&self, tab_id: String) -> bool {
pub(super) fn submit_poll(&self, generation: RequestGeneration, tab_id: String) -> bool {
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
@@ -194,15 +218,25 @@ impl LiveRuntimeWorker {
if q.shutdown {
return false;
}
if let Some(message) = q.initialization_failure.clone() {
drop(q);
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return true;
}
// A pending Ensure already produces the latest frame after its
// run; don't downgrade it to a Poll. Only insert if nothing is
// queued.
let inserted = match q.pending.entry(tab_id.clone()) {
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(WorkerRequest::Poll { tab_id });
true
}
std::collections::btree_map::Entry::Occupied(_) => false,
let inserted = if q.pending.contains_key(&tab_id)
|| q.in_flight_tab.as_deref() == Some(tab_id.as_str())
{
false
} else {
q.pending.insert(
tab_id.clone(),
VecDeque::from([WorkerRequest::Poll { generation, tab_id: tab_id.clone() }]),
);
q.ready_tabs.push_back(tab_id);
true
};
cvar.notify_one();
inserted
@@ -217,8 +251,12 @@ impl LiveRuntimeWorker {
if q.shutdown {
return;
}
if q.initialization_failure.is_some() {
return;
}
q.pending.remove(&tab_id);
q.closes.push(tab_id);
q.ready_tabs.retain(|ready_tab_id| ready_tab_id != &tab_id);
q.closes.push_back(tab_id);
cvar.notify_one();
}
@@ -230,10 +268,7 @@ impl LiveRuntimeWorker {
out
}
/// Test-only barrier. Blocks the caller until the worker has
/// drained everything currently submitted. Production code never
/// waits — the whole point of the worker is that the UI thread
/// progresses without IPC latency.
/// Test-only barrier for all submitted work.
#[cfg(test)]
pub(super) fn wait_until_idle(&self) {
let (lock, cvar) = &*self.queue;
@@ -254,10 +289,12 @@ impl Drop for LiveRuntimeWorker {
fn drop(&mut self) {
{
let (lock, cvar) = &*self.queue;
if let Ok(mut q) = lock.lock() {
q.shutdown = true;
cvar.notify_all();
}
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.shutdown = true;
cvar.notify_all();
}
if let Some(handle) = self.thread.take() {
let _ = handle.join();
@@ -265,12 +302,41 @@ impl Drop for LiveRuntimeWorker {
}
}
fn fail_worker_initialization(
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: &mpsc::Sender<WorkerResponse>,
message: String,
) {
let (lock, cvar) = &*queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.initialization_failure = Some(message.clone());
q.ready_tabs.clear();
q.closes.clear();
q.in_flight = false;
q.in_flight_tab = None;
let pending = std::mem::take(&mut q.pending);
for request in pending.into_values().flatten() {
let (generation, tab_id) = request.failure_parts();
let _ = response_tx.send(WorkerResponse::Failed {
generation,
tab_id,
message: message.clone(),
});
}
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
cvar.notify_all();
}
fn run_worker(
mut client: Box<dyn LiveRuntimeClient>,
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
) {
let (lock, cvar) = &*queue;
let mut last_dispatched_tab = None;
loop {
let work = {
let mut q = match lock.lock() {
@@ -278,6 +344,7 @@ fn run_worker(
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
q.in_flight_tab = None;
cvar.notify_all();
while q.pending.is_empty() && q.closes.is_empty() && !q.shutdown {
q = match cvar.wait(q) {
@@ -288,19 +355,39 @@ fn run_worker(
if q.shutdown {
return;
}
let next = if let Some(close_id) = q.closes.pop() {
let next = if let Some(close_id) = q.closes.pop_front() {
Work::Close(close_id)
} else {
let key = match q.pending.keys().next().cloned() {
Some(key) => key,
if q.ready_tabs.len() > 1
&& q.ready_tabs.front() == last_dispatched_tab.as_ref()
&& let Some(last_tab) = q.ready_tabs.pop_front()
{
q.ready_tabs.push_back(last_tab);
}
let tab_id = match q.ready_tabs.pop_front() {
Some(tab_id) => tab_id,
None => continue,
};
let Some(request) = q.pending.remove(&key) else {
continue;
let (request, has_more) = match q.pending.get_mut(&tab_id) {
Some(pending) => match pending.pop_front() {
Some(request) => (request, !pending.is_empty()),
None => continue,
},
None => continue,
};
if has_more {
q.ready_tabs.push_back(tab_id.clone());
} else {
q.pending.remove(&tab_id);
}
last_dispatched_tab = Some(tab_id);
Work::Request(request)
};
q.in_flight = true;
q.in_flight_tab = match &next {
Work::Close(_) => None,
Work::Request(request) => Some(request.tab_id().to_string()),
};
next
};
@@ -309,25 +396,23 @@ fn run_worker(
let _ = client.close(tab_id);
false
}
Work::Request(WorkerRequest::Ensure(request)) => {
Work::Request(WorkerRequest::Ensure { generation, request }) => {
let tab_id = request.tab_id.clone();
dispatch_result(&response_tx, tab_id, client.ensure(request))
dispatch_result(&response_tx, generation, tab_id, client.ensure(request))
}
Work::Request(WorkerRequest::Poll { tab_id }) => {
Work::Request(WorkerRequest::Poll { generation, tab_id }) => {
let request_tab_id = tab_id.clone();
dispatch_result(&response_tx, request_tab_id, client.poll(tab_id))
dispatch_result(&response_tx, generation, request_tab_id, client.poll(tab_id))
}
};
if exit_after_dispatch {
// Release the in-flight flag and wake any flush waiter
// before exiting so wait_until_idle doesn't block forever
// on a thread that has already returned.
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
q.in_flight_tab = None;
cvar.notify_all();
return;
}
@@ -343,19 +428,20 @@ enum Work {
/// `true` when the worker should exit.
fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
generation: RequestGeneration,
tab_id: String,
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
) -> bool {
match result {
Ok(Some(frame)) => {
let _ = response_tx.send(WorkerResponse::Frame { tab_id, frame });
let _ = response_tx.send(WorkerResponse::Frame { generation, tab_id, frame });
false
}
Ok(None) => false,
Err(error) => {
let unavailable = error.is_runtime_unavailable();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
let _ = response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
if unavailable {
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
return true;
@@ -364,3 +450,34 @@ fn dispatch_result(
}
}
}
fn request_has_ordered_input(request: &WorkerRequest) -> bool {
let WorkerRequest::Ensure { request, .. } = request else {
return false;
};
request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.scroll_point_x.is_some()
|| request.scroll_point_y.is_some()
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.typed_text.is_some()
}
fn preserve_latest_hover(latest: &mut WorkerRequest, previous: &WorkerRequest) {
let (
WorkerRequest::Ensure { request: latest, .. },
WorkerRequest::Ensure { request: previous, .. },
) = (latest, previous)
else {
return;
};
if latest.hover_x.is_none() && latest.hover_y.is_none() {
latest.hover_x = previous.hover_x;
latest.hover_y = previous.hover_y;
}
}
#[cfg(test)]
#[path = "web_surface_worker_tests.rs"]
mod tests;