perf(ipc): cache unchanged output event payloads

This commit is contained in:
Samuel Onoja committed 2026-10-24 22:03:23 +01:00
1 parent 835aadc3eb
commit e9f36efdb6
6 files changed
+397 -16

No files matched your search

+2 -2
View File
@@ -246,7 +246,7 @@ impl DirectBackendState {
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct ConnectedOutputInfo { pub(crate) struct ConnectedOutputInfo {
pub connector: String, pub connector: String,
pub connected: bool, pub connected: bool,
@@ -266,7 +266,7 @@ pub(crate) struct ConnectedOutputInfo {
pub configured_position: Option<[i32; 2]>, pub configured_position: Option<[i32; 2]>,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ConnectedModeInfo { pub(crate) struct ConnectedModeInfo {
pub width: u16, pub width: u16,
pub height: u16, pub height: u16,
+34 -7
View File
@@ -14,6 +14,7 @@ use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION}; use ferese_ipc::{Request, Response, VERSION};
pub(crate) mod activity_owner; pub(crate) mod activity_owner;
pub(crate) mod outputs;
mod transport; mod transport;
use ferese_layout::Direction; use ferese_layout::Direction;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -1620,15 +1621,41 @@ impl Ferese {
pub(crate) fn ipc_event_snapshot(&self) -> ferese_ipc::events::Snapshot { pub(crate) fn ipc_event_snapshot(&self) -> ferese_ipc::events::Snapshot {
let locked = self.session_lock.active(); let locked = self.session_lock.active();
let mut outputs = self.outputs_json(); let outputs = if let Some(backend) = &self.direct_backend {
if locked { let state = outputs::State {
for output in outputs.as_array_mut().into_iter().flatten() { connected: &backend.connected_outputs,
if let Some(fields) = output.as_object_mut() { focused: self.output_workspaces.focused_output(),
fields.remove("workspace"); error: backend.output_configuration_error(),
fields.remove("focused"); confirmation_pending: backend.confirmation_pending(),
low_power: backend.low_power,
};
let presentations = backend.connected_outputs.iter().map(|info| {
let mapped = self.output_by_identity(&info.identity);
let id = self.persistent_output_id(&info.identity);
outputs::Presentation {
id,
geometry: mapped.and_then(|output| self.space.output_geometry(output)),
workspace: id.and_then(|id| self.output_workspaces.active_workspace(id)),
scale: mapped
.map(|output| output.current_scale().fractional_scale())
.unwrap_or(info.scale),
transform: mapped
.map(|output| transform_name(output.current_transform()))
.unwrap_or_else(|| configured_transform_name(info.transform)),
} }
});
self.ipc_events
.outputs
.borrow_mut()
.get_or_update(state, presentations, locked, || self.outputs_json())
} else {
// Nested outputs have no connector inventory or DRM mode lists.
let mut value = self.outputs_json();
if locked {
outputs::redact(&mut value);
} }
} Arc::new(value)
};
let windows = if locked { let windows = if locked {
Vec::new() Vec::new()
} else { } else {
+318
View File
@@ -0,0 +1,318 @@
use std::sync::Arc;
use ferese_core::{OutputId, WorkspaceId};
use serde_json::Value;
use smithay::utils::{Logical, Rectangle};
use crate::backends::direct::ConnectedOutputInfo;
/// Connector data and policy borrowed for an exact, allocation-free comparison.
pub(crate) struct State<'a> {
pub connected: &'a [ConnectedOutputInfo],
pub focused: Option<OutputId>,
pub error: Option<&'a str>,
pub confirmation_pending: bool,
pub low_power: bool,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct Presentation {
pub id: Option<OutputId>,
pub geometry: Option<Rectangle<i32, Logical>>,
pub workspace: Option<WorkspaceId>,
pub scale: f64,
pub transform: &'static str,
}
struct Entry {
connected: Vec<ConnectedOutputInfo>,
focused: Option<OutputId>,
error: Option<String>,
confirmation_pending: bool,
low_power: bool,
presentations: Vec<Presentation>,
unlocked: Arc<Value>,
locked: Option<Arc<Value>>,
}
#[derive(Default)]
pub(crate) struct Cache {
entry: Option<Entry>,
}
impl Cache {
pub(crate) fn clear(&mut self) {
self.entry = None;
}
pub(crate) fn get_or_update(
&mut self,
state: State<'_>,
presentations: impl Iterator<Item = Presentation> + Clone,
locked: bool,
build: impl FnOnce() -> Value,
) -> Arc<Value> {
let unchanged = self.entry.as_ref().is_some_and(|entry| {
entry.connected == state.connected
&& entry.focused == state.focused
&& entry.error.as_deref() == state.error
&& entry.confirmation_pending == state.confirmation_pending
&& entry.low_power == state.low_power
&& presentations
.clone()
.eq(entry.presentations.iter().copied())
});
if !unchanged {
self.entry = Some(Entry {
connected: state.connected.to_vec(),
focused: state.focused,
error: state.error.map(str::to_owned),
confirmation_pending: state.confirmation_pending,
low_power: state.low_power,
presentations: presentations.collect(),
unlocked: Arc::new(build()),
locked: None,
});
}
let entry = self.entry.as_mut().expect("output snapshot was populated");
if locked {
entry
.locked
.get_or_insert_with(|| {
let mut value = entry.unlocked.as_ref().clone();
redact(&mut value);
Arc::new(value)
})
.clone()
} else {
entry.unlocked.clone()
}
}
}
pub(super) fn redact(value: &mut Value) {
for output in value.as_array_mut().into_iter().flatten() {
if let Some(fields) = output.as_object_mut() {
fields.remove("workspace");
fields.remove("focused");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::direct::ConnectedModeInfo;
use crate::config::OutputTransform;
use serde_json::json;
fn connector() -> ConnectedOutputInfo {
let mode = ConnectedModeInfo {
width: 3840,
height: 2160,
refresh_millihertz: 60_000,
preferred: true,
};
ConnectedOutputInfo {
connector: "HDMI-A-1".into(),
connected: true,
identity: "test-monitor".into(),
internal: false,
requested_enabled: true,
mirror_source: None,
enabled: true,
profile: Some("desktop".into()),
requested_profile: None,
auto_refresh: true,
physical_size: Some((600, 340)),
current_mode: Some(mode),
available_modes: vec![mode],
scale: 1.75,
transform: OutputTransform::Normal,
configured_position: Some([0, 0]),
}
}
fn state(connected: &[ConnectedOutputInfo]) -> State<'_> {
State {
connected,
focused: Some(OutputId(1)),
error: None,
confirmation_pending: false,
low_power: false,
}
}
fn presentation() -> Presentation {
Presentation {
id: Some(OutputId(1)),
geometry: Some(Rectangle::new((0, 0).into(), (2195, 1235).into())),
workspace: Some(WorkspaceId(3)),
scale: 1.75,
transform: "normal",
}
}
fn value() -> Value {
json!([{"id": 1, "workspace": 3, "focused": true, "scale": 1.75}])
}
#[test]
fn unchanged_outputs_reuse_storage_without_serializing_and_release_when_cleared() {
let connected = [connector()];
let mut cache = Cache::default();
let first = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
for _ in 0..1000 {
let next = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
|| panic!("unchanged outputs must not rebuild JSON"),
);
assert!(Arc::ptr_eq(&first, &next));
}
cache.clear();
let next = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
assert!(!Arc::ptr_eq(&first, &next));
assert_eq!(first, next);
}
#[test]
fn lock_redaction_is_cached_without_mutating_public_or_older_snapshots() {
let connected = [connector()];
let mut cache = Cache::default();
let public = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
let locked = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
true,
|| panic!("lock redaction must reuse connector data"),
);
assert_eq!(*locked, json!([{"id": 1, "scale": 1.75}]));
assert_eq!(*public, value());
let again = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
true,
|| panic!("repeated locked snapshots must not rebuild"),
);
assert!(Arc::ptr_eq(&locked, &again));
let unlocked = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
|| panic!("unlock must reuse public data"),
);
assert!(Arc::ptr_eq(&public, &unlocked));
let changed = cache.get_or_update(state(&[]), [].into_iter(), true, || json!([]));
assert_eq!(*changed, json!([]));
assert_eq!(*locked, json!([{"id": 1, "scale": 1.75}]));
}
#[test]
fn connector_inventory_and_every_reported_property_invalidate_the_snapshot() {
let changes: &[fn(&mut ConnectedOutputInfo)] = &[
|info| info.connector.push('2'),
|info| info.connected = false,
|info| info.identity.push('2'),
|info| info.internal = true,
|info| info.requested_enabled = false,
|info| info.mirror_source = Some("DP-1".into()),
|info| info.enabled = false,
|info| info.profile = None,
|info| info.requested_profile = Some("mobile".into()),
|info| info.auto_refresh = false,
|info| info.physical_size = None,
|info| info.current_mode = None,
|info| info.available_modes[0].width = 1920,
|info| info.available_modes[0].height = 1080,
|info| info.available_modes[0].refresh_millihertz = 120_000,
|info| info.available_modes[0].preferred = false,
|info| info.available_modes.clear(),
|info| info.scale = 1.5,
|info| info.transform = OutputTransform::Rotate90,
|info| info.configured_position = None,
];
for (index, change) in changes.iter().enumerate() {
let mut cache = Cache::default();
let mut connected = [connector()];
let first = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
change(&mut connected[0]);
let next = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
|| json!({"changed": index}),
);
assert_eq!(*next, json!({"changed": index}));
assert!(!Arc::ptr_eq(&first, &next));
}
let mut cache = Cache::default();
let first = cache.get_or_update(
state(&[connector()]),
[presentation()].into_iter(),
false,
value,
);
let next = cache.get_or_update(state(&[]), [].into_iter(), false, || json!([]));
assert_eq!(*next, json!([]));
assert_eq!(*first, value());
}
#[test]
fn focus_workspace_geometry_and_output_policy_changes_refresh_the_snapshot() {
let connected = [connector()];
for index in 0..10 {
let mut cache = Cache::default();
let first = cache.get_or_update(
state(&connected),
[presentation()].into_iter(),
false,
value,
);
let mut state = state(&connected);
let mut presentation = presentation();
match index {
0 => state.focused = None,
1 => state.error = Some("output rejected"),
2 => state.confirmation_pending = true,
3 => state.low_power = true,
4 => presentation.id = None,
5 => presentation.geometry = None,
6 => presentation.geometry.as_mut().unwrap().loc.x += 100,
7 => presentation.workspace = Some(WorkspaceId(4)),
8 => presentation.scale = 1.25,
9 => presentation.transform = "rotate_90",
_ => unreachable!(),
}
let next = cache.get_or_update(
state,
[presentation].into_iter(),
false,
|| json!({"changed": index}),
);
assert_eq!(*next, json!({"changed": index}));
assert!(!Arc::ptr_eq(&first, &next));
}
}
}
+9 -3
View File
@@ -1,3 +1,4 @@
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::mpsc::SyncSender; use std::sync::mpsc::SyncSender;
@@ -18,6 +19,7 @@ pub(crate) struct Subscribers {
previous: Option<Snapshot>, previous: Option<Snapshot>,
generation: u64, generation: u64,
pub(crate) config_revision: u64, pub(crate) config_revision: u64,
pub(crate) outputs: RefCell<crate::ipc::outputs::Cache>,
} }
impl Subscribers { impl Subscribers {
@@ -43,6 +45,7 @@ impl Subscribers {
self.subscribers.remove(&owner); self.subscribers.remove(&owner);
if self.subscribers.is_empty() { if self.subscribers.is_empty() {
self.previous = None; self.previous = None;
self.outputs.get_mut().clear();
} }
} }
@@ -121,6 +124,9 @@ impl Subscribers {
delivered delivered
}); });
self.previous = self.active().then_some(snapshot); self.previous = self.active().then_some(snapshot);
if !self.active() {
self.outputs.get_mut().clear();
}
} }
} }
@@ -246,7 +252,7 @@ mod tests {
fn snapshot() -> Snapshot { fn snapshot() -> Snapshot {
Snapshot { Snapshot {
outputs: json!([]), outputs: json!([]).into(),
workspaces: json!([]), workspaces: json!([]),
windows: Vec::new(), windows: Vec::new(),
focus: json!(null), focus: json!(null),
@@ -272,7 +278,7 @@ mod tests {
)); ));
hub.publish(state.clone()); hub.publish(state.clone());
assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty)));
state.outputs = json!([{"id": 2}]); state.outputs = json!([{"id": 2}]).into();
state.workspaces = json!([{"output": 2}]); state.workspaces = json!([{"output": 2}]);
state.focus = json!({"output": 2}); state.focus = json!({"output": 2});
hub.publish(state); hub.publish(state);
@@ -356,7 +362,7 @@ mod tests {
let mut state = snapshot(); let mut state = snapshot();
hub.publish(state.clone()); hub.publish(state.clone());
receive.try_recv().unwrap(); receive.try_recv().unwrap();
state.outputs = json!([{"id": 1}]); state.outputs = json!([{"id": 1}]).into();
state.workspaces = json!([{"output": 1}]); state.workspaces = json!([{"output": 1}]);
state.windows = vec![ferese_ipc::events::Window { state.windows = vec![ferese_ipc::events::Window {
id: 7, id: 7,
+33 -3
View File
@@ -33,7 +33,7 @@ pub struct Window {
/// A coherent logical desktop view. Desktop objects are redacted while locked. /// A coherent logical desktop view. Desktop objects are redacted while locked.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Snapshot { pub struct Snapshot {
pub outputs: Value, pub outputs: Arc<Value>,
pub workspaces: Value, pub workspaces: Value,
pub windows: Vec<Window>, pub windows: Vec<Window>,
pub focus: Value, pub focus: Value,
@@ -80,9 +80,9 @@ impl Snapshot {
#[must_use] #[must_use]
pub fn changes_since(&self, previous: &Self) -> Vec<Change> { pub fn changes_since(&self, previous: &Self) -> Vec<Change> {
let mut changes = Vec::new(); let mut changes = Vec::new();
if self.outputs != previous.outputs { if !Arc::ptr_eq(&self.outputs, &previous.outputs) && self.outputs != previous.outputs {
changes.push(Change::OutputsChanged { changes.push(Change::OutputsChanged {
outputs: self.outputs.clone(), outputs: self.outputs.as_ref().clone(),
}); });
} }
if self.workspaces != previous.workspaces { if self.workspaces != previous.workspaces {
@@ -123,6 +123,36 @@ impl Snapshot {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn shared_outputs_preserve_wire_fields_and_detect_value_changes() {
let outputs = serde_json::json!([{
"id": 1, "connector": "HDMI-A-1", "scale": 1.75,
"available_modes": [{"width": 3840, "height": 2160, "preferred": true}],
"configuration_error": null
}]);
let wire = serde_json::json!({
"outputs": outputs, "workspaces": [], "windows": [], "focus": null,
"config": {"revision": 0, "error": null}, "theme": null,
"lock": {"phase": "unlocked", "sleeping": false}
});
let snapshot: Snapshot = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(&snapshot).unwrap(), wire);
let mut next = snapshot.clone();
assert!(Arc::ptr_eq(&snapshot.outputs, &next.outputs));
assert!(next.changes_since(&snapshot).is_empty());
next.outputs = Arc::new(outputs.clone());
assert!(next.changes_since(&snapshot).is_empty());
next.outputs = serde_json::json!([]).into();
let changes = next.changes_since(&snapshot);
assert_eq!(
serde_json::to_value(&changes).unwrap(),
serde_json::json!([
{"type": "outputs_changed", "outputs": []}
])
);
assert_eq!(*snapshot.outputs, outputs);
}
#[test] #[test]
fn typed_windows_preserve_snapshot_and_replacement_wire_fields() { fn typed_windows_preserve_snapshot_and_replacement_wire_fields() {
let windows = serde_json::json!([{ let windows = serde_json::json!([{
+1 -1
View File
@@ -527,7 +527,7 @@ mod tests {
last: true, last: true,
change: Change::Snapshot { change: Change::Snapshot {
desktop: Box::new(ferese_ipc::events::Snapshot { desktop: Box::new(ferese_ipc::events::Snapshot {
outputs: serde_json::json!([]), outputs: serde_json::json!([]).into(),
workspaces: serde_json::json!([]), workspaces: serde_json::json!([]),
windows: vec![window(1, "one.desktop", true, 1)], windows: vec![window(1, "one.desktop", true, 1)],
focus: serde_json::json!({"window":1}), focus: serde_json::json!({"window":1}),