diff --git a/Cargo.toml b/Cargo.toml index 22043d4..87a97a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ smithay = { git = "https://github.com/Smithay/smithay", rev = "8a270232f1bfc416c ] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } -serde = { version = "1.0.228", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive", "rc"] } serde_json = "1.0.145" tempfile = "3.23.0" xcursor = "0.3.11" diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index db1c334..4b6a0fb 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -1630,24 +1630,31 @@ impl Ferese { } } let windows = if locked { - json!([]) + Vec::new() } else { - Value::Array(self.windows.ordered_ids().filter_map(|id| { - let record = self.windows.record(id)?; - let workspace = self.workspaces.workspace_for_window(id)?; - Some(json!({ - "id": id.0, "app_id": record.app_id, "title": record.title, - "focused": self.focused_window == Some(id), "workspace": workspace.0, - "floating": matches!(self.workspaces.placement(id), Some(ferese_core::WindowPlacement::Floating { .. })), - "fullscreen": self.workspaces.workspace(workspace).is_some_and(|workspace| workspace.fullscreen == Some(id)), - })) - }).collect()) + self.windows + .ordered_ids() + .filter_map(|id| { + let record = self.windows.record(id)?; + let workspace = self.workspaces.workspace_for_window(id)?; + Some(ferese_ipc::events::Window { + id: id.0, + app_id: record.app_id.clone(), + title: record.title.clone(), + focused: self.focused_window == Some(id), + workspace: workspace.0, + floating: matches!( + self.workspaces.placement(id), + Some(ferese_core::WindowPlacement::Floating { .. }) + ), + fullscreen: self + .workspaces + .workspace(workspace) + .is_some_and(|workspace| workspace.fullscreen == Some(id)), + }) + }) + .collect() }; - let live = &self.theme_engine.live; - let theme = json!({ - "mode": live.mode, "theme": live.theme, "families": live.families, - "warnings": live.warnings, "error": live.error, "fallback_note": live.fallback_note, - }); ferese_ipc::events::Snapshot { outputs, workspaces: if locked { @@ -1669,7 +1676,7 @@ impl Ferese { revision: self.ipc_events.config_revision, error: self.theme_engine.live.error.clone(), }, - theme, + theme: self.theme_engine.event_value(), lock: self.session_lock.event_state(), } } @@ -2512,7 +2519,15 @@ mod tests { generation: 1, last: true, change: ferese_ipc::events::Change::WindowsChanged { - windows: json!([{"title": "x".repeat(900_000)}]), + windows: vec![ferese_ipc::events::Window { + id: 1, + app_id: "org.example.Test".into(), + title: "x".repeat(900_000), + focused: false, + workspace: 1, + floating: false, + fullscreen: false, + }], }, })) .unwrap(); diff --git a/compositor/src/ipc_events.rs b/compositor/src/ipc_events.rs index ddddd31..d0351f4 100644 --- a/compositor/src/ipc_events.rs +++ b/compositor/src/ipc_events.rs @@ -63,6 +63,9 @@ impl Subscribers { .values() .any(|subscriber| subscriber.initial) { + // Keep the latest shared payload even when a theme publication + // produced equal logical values, so later comparisons stay cheap. + self.previous = self.active().then_some(snapshot); return; } self.generation = self.generation.wrapping_add(1); @@ -144,14 +147,111 @@ mod tests { use serde_json::json; use std::sync::mpsc::{TryRecvError, sync_channel}; + #[test] + #[ignore = "manual release benchmark for IPC snapshot construction"] + fn profile_unchanged_event_snapshots() { + let name = "ipc_events::tests::profile_unchanged_event_snapshots"; + if !crate::startup_tests::private_runtime(name) { + return; + } + let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let state = crate::startup_tests::state(&mut events); + for _ in 0..100 { + std::hint::black_box(state.ipc_event_snapshot()); + } + for run in 1..=3 { + let started = std::time::Instant::now(); + for _ in 0..2000 { + std::hint::black_box(state.ipc_event_snapshot()); + } + eprintln!( + "event_snapshot_benchmark run={run} iterations=2000 elapsed_us={}", + started.elapsed().as_micros() + ); + } + } + + #[test] + #[ignore = "manual release benchmark for populated IPC publication"] + fn profile_populated_event_publication() { + use crate::state::DesktopOutput; + use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel}; + use smithay::utils::Transform; + + if !crate::startup_tests::private_runtime( + "ipc_events::tests::profile_populated_event_publication", + ) { + return; + } + let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + let output = DesktopOutput { + output: Output::new( + "benchmark".into(), + PhysicalProperties { + serial_number: String::new(), + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ), + identity: "benchmark".into(), + mode: Mode { + size: (1920, 1080).into(), + refresh: 60_000, + }, + transform: Transform::Normal, + scale: Scale::Fractional(1.0), + position: (0, 0).into(), + }; + state.begin_desktop_transition(); + let changes = state.publish_desktop(vec![output]).unwrap(); + state.finish_desktop_transition(changes); + let mut clients = Vec::new(); + for window_count in [0, 1, 20, 100] { + while clients.len() < window_count { + let (window, client) = + crate::state::test_window(&mut state, &mut events, 0xff204060); + let id = state.windows.ids()[&window]; + let record = state.windows.record_mut(id).unwrap(); + record.app_id = "org.example.Editor".into(); + record.title = format!("Document {} — publication benchmark", id.0); + clients.push(client); + } + let (sender, receive) = sync_channel(QUEUE_CAPACITY); + state.ipc_events.subscribe(1, sender).unwrap(); + state.publish_ipc_events(); + assert!(matches!( + receive.try_recv().unwrap().change, + Change::Snapshot { .. } + )); + for _ in 0..100 { + state.publish_ipc_events(); + } + for run in 1..=3 { + let started = std::time::Instant::now(); + for _ in 0..2000 { + state.publish_ipc_events(); + } + eprintln!( + "event_publication_benchmark windows={window_count} run={run} iterations=2000 elapsed_us={}", + started.elapsed().as_micros() + ); + } + assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); + state.ipc_events.remove(1); + } + } + fn snapshot() -> Snapshot { Snapshot { outputs: json!([]), workspaces: json!([]), - windows: json!([]), + windows: Vec::new(), focus: json!(null), config: ConfigState::default(), - theme: json!({}), + theme: json!({}).into(), lock: LockState { phase: "unlocked".into(), sleeping: false, @@ -186,6 +286,24 @@ mod tests { assert!(!events[0].last && !events[1].last && events[2].last); } + #[test] + fn equal_theme_publications_retain_shared_storage_without_emitting_events() { + let mut hub = Subscribers::default(); + let (sender, receive) = sync_channel(QUEUE_CAPACITY); + hub.subscribe(1, sender).unwrap(); + let first = snapshot(); + hub.publish(first.clone()); + receive.try_recv().unwrap(); + let generation = hub.generation; + let mut next = first; + next.theme = Arc::new(next.theme.as_ref().clone()); + let payload = next.theme.clone(); + hub.publish(next); + assert!(matches!(receive.try_recv(), Err(TryRecvError::Empty))); + assert_eq!(hub.generation, generation); + assert!(Arc::ptr_eq(&hub.previous.as_ref().unwrap().theme, &payload)); + } + #[test] fn full_and_disconnected_subscribers_do_not_block_healthy_readers() { let mut hub = Subscribers::default(); @@ -240,10 +358,18 @@ mod tests { receive.try_recv().unwrap(); state.outputs = json!([{"id": 1}]); state.workspaces = json!([{"output": 1}]); - state.windows = json!([{"id": 7, "title": "Terminal"}]); + state.windows = vec![ferese_ipc::events::Window { + id: 7, + app_id: "org.example.Terminal".into(), + title: "Terminal".into(), + focused: false, + workspace: 1, + floating: false, + fullscreen: false, + }]; state.focus = json!({"window": 7}); state.config.revision = 1; - state.theme = json!({"mode": "light"}); + state.theme = json!({"mode": "light"}).into(); state.lock.phase = "locked".into(); hub.publish(state); let events = receive.try_iter().collect::>(); diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index 4032c64..d700043 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -1,3 +1,4 @@ +use std::cell::OnceCell; use std::collections::{HashMap, HashSet}; use std::io; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; @@ -138,6 +139,7 @@ impl Default for LiveTheme { pub(crate) struct Engine { pub(crate) live: LiveTheme, pub(crate) revision: u64, + event_value: OnceCell>, waiters: HashMap)>, watcher: Option, watched_directories: HashSet, @@ -207,7 +209,24 @@ impl Engine { serde_json::to_value(snapshot).expect("serializable theme snapshot") } + /// Share the logical event payload until the next theme publication. + pub(crate) fn event_value(&self) -> Arc { + self.event_value + .get_or_init(|| { + Arc::new(json!({ + "mode": self.live.mode, + "theme": self.live.theme, + "families": self.live.families, + "warnings": self.live.warnings, + "error": self.live.error, + "fallback_note": self.live.fallback_note, + })) + }) + .clone() + } + fn publish(&mut self) { + self.event_value.take(); self.revision = self.revision.wrapping_add(1); let value = self.value(); for (_, (id, response)) in self.waiters.drain() { @@ -216,6 +235,7 @@ impl Engine { } pub(crate) fn startup_failed(&mut self, path: Option<&Path>, error: String) { + self.event_value.take(); let path = path .map(|path| path.display().to_string()) .unwrap_or_else(|| "configuration".into()); @@ -793,6 +813,42 @@ mod tests { assert_eq!(engine.revision, 3); } + #[test] + fn event_values_reuse_storage_and_follow_theme_and_error_publication() { + let mut engine = Engine::default(); + let original = engine.event_value(); + let mut expected = engine.value(); + for key in ["version", "revision", "presented"] { + expected.as_object_mut().unwrap().remove(key); + } + assert_eq!(original.as_ref(), &expected); + assert!(Arc::ptr_eq(&original, &engine.event_value())); + + engine.live.mode = Mode::Light; + engine.publish(); + let light = engine.event_value(); + assert!(!Arc::ptr_eq(&original, &light)); + assert_eq!(light["mode"], "light"); + assert_eq!( + original["mode"], "dark", + "queued snapshots remain immutable" + ); + + engine.reject("invalid theme".into()); + let rejected = engine.event_value(); + assert_eq!(rejected["error"], "invalid theme"); + assert!(light["error"].is_null()); + engine.startup_failed(None, "invalid startup configuration".into()); + let startup = engine.event_value(); + assert!( + startup["error"] + .as_str() + .unwrap() + .contains("invalid startup configuration") + ); + assert!(Arc::ptr_eq(&startup, &engine.event_value())); + } + #[test] fn publication_projects_live_values_and_preserves_revision_semantics() { let mut engine = Engine::default(); diff --git a/crates/ferese-ipc/src/events.rs b/crates/ferese-ipc/src/events.rs index 6e91c5e..ec4526d 100644 --- a/crates/ferese-ipc/src/events.rs +++ b/crates/ferese-ipc/src/events.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -16,15 +18,27 @@ pub struct LockState { pub sleeping: bool, } +/// Window metadata included in desktop event snapshots. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Window { + pub id: u64, + pub app_id: String, + pub title: String, + pub focused: bool, + pub workspace: u64, + pub floating: bool, + pub fullscreen: bool, +} + /// A coherent logical desktop view. Desktop objects are redacted while locked. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Snapshot { pub outputs: Value, pub workspaces: Value, - pub windows: Value, + pub windows: Vec, pub focus: Value, pub config: ConfigState, - pub theme: Value, + pub theme: Arc, pub lock: LockState, } @@ -35,7 +49,7 @@ pub enum Change { Snapshot { desktop: Box }, OutputsChanged { outputs: Value }, WorkspacesChanged { workspaces: Value }, - WindowsChanged { windows: Value }, + WindowsChanged { windows: Vec }, FocusChanged { focus: Value }, ConfigChanged { config: ConfigState }, ThemeChanged { theme: Value }, @@ -91,9 +105,9 @@ impl Snapshot { config: self.config.clone(), }); } - if self.theme != previous.theme { + if !Arc::ptr_eq(&self.theme, &previous.theme) && self.theme != previous.theme { changes.push(Change::ThemeChanged { - theme: self.theme.clone(), + theme: self.theme.as_ref().clone(), }); } if self.lock != previous.lock { @@ -108,6 +122,75 @@ impl Snapshot { #[cfg(test)] mod tests { use super::*; + + #[test] + fn typed_windows_preserve_snapshot_and_replacement_wire_fields() { + let windows = serde_json::json!([{ + "id": u64::MAX, + "app_id": "org.example.Editor.desktop", + "title": "Chapter \"one\"\n第二课", + "focused": true, + "workspace": u64::MAX - 1, + "floating": true, + "fullscreen": false + }]); + let snapshot = serde_json::json!({ + "outputs": [], "workspaces": [], "windows": windows, + "focus": {"window": u64::MAX}, + "config": {"revision": 0, "error": null}, + "theme": null, "lock": {"phase": "unlocked", "sleeping": false} + }); + for change in [ + serde_json::json!({"type": "snapshot", "desktop": snapshot}), + serde_json::json!({"type": "windows_changed", "windows": windows}), + ] { + let decoded: Change = serde_json::from_value(change.clone()).unwrap(); + assert_eq!(serde_json::to_value(&decoded).unwrap(), change); + let decoded_windows = match decoded { + Change::Snapshot { desktop } => desktop.windows, + Change::WindowsChanged { windows } => windows, + _ => unreachable!(), + }; + assert_eq!( + decoded_windows, + vec![Window { + id: u64::MAX, + app_id: "org.example.Editor.desktop".into(), + title: "Chapter \"one\"\n第二课".into(), + focused: true, + workspace: u64::MAX - 1, + floating: true, + fullscreen: false, + }] + ); + } + } + + #[test] + fn shared_themes_preserve_the_wire_format_and_compare_by_value_after_replacement() { + let wire = serde_json::json!({ + "outputs": [], "workspaces": [], "windows": [], "focus": null, + "config": {"revision": 0, "error": null}, + "theme": {"mode": "dark"}, + "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.theme, &next.theme)); + assert!(next.changes_since(&snapshot).is_empty()); + next.theme = Arc::new(next.theme.as_ref().clone()); + assert!(!Arc::ptr_eq(&snapshot.theme, &next.theme)); + assert!(next.changes_since(&snapshot).is_empty()); + next.theme = serde_json::json!({"mode": "light"}).into(); + assert_eq!( + next.changes_since(&snapshot), + [Change::ThemeChanged { + theme: serde_json::json!({"mode": "light"}) + }] + ); + } + #[test] fn events_round_trip_with_an_independent_schema_version() { let event = Event { diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 010dbeb..bbed5a5 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -520,7 +520,7 @@ impl Dispatch for ControlState { { let _ = state .sender - .send(ControlUpdate::Snapshot(state.pending.clone())); + .send(ControlUpdate::Snapshot(std::mem::take(&mut state.pending))); } ferese_shell_v1::Event::LogoutRequested { serial, @@ -656,6 +656,107 @@ mod tests { } } + #[test] + #[ignore = "manual release benchmark for shell snapshot handoff"] + fn profile_snapshot_handoff() { + let (control, mut state, queue, _peer) = control_fixture(); + for window_count in [1, 20, 100] { + let template = ShellSnapshot { + windows: (0..window_count) + .map(|id| WindowSnapshot { + id, + workspace: 1, + geometry: Some([0., 0., 800., 600.]), + app_id: "org.example.Editor".into(), + title: format!("Document {id} — Ferese workspace"), + focused: id == 0, + urgent: false, + fullscreen: false, + maximized: false, + floating: false, + }) + .collect(), + ..ShellSnapshot::default() + }; + for run in 1..=3 { + // Decoding is outside the timed handoff, as it is unchanged. + let snapshots: Vec<_> = (0..2000).map(|_| template.clone()).collect(); + let started = std::time::Instant::now(); + for snapshot in snapshots { + state.pending = snapshot; + state.serial = Some(1); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: 1 }, + ); + std::hint::black_box(control.poll().snapshot.unwrap()); + } + // Include destruction of the last pending snapshot in both builds. + state.pending = ShellSnapshot::default(); + eprintln!( + "snapshot_handoff_benchmark windows={window_count} run={run} iterations=2000 elapsed_us={}", + started.elapsed().as_micros() + ); + } + } + } + + #[test] + fn completed_snapshots_transfer_their_storage_and_remain_independent() { + let (control, mut state, queue, _peer) = control_fixture(); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotBegin { serial: 1 }, + ); + dispatch_event(&control, &mut state, &queue, window_event(10, 0)); + let windows = state.pending.windows.as_ptr(); + let app_id = state.pending.windows[0].app_id.as_ptr(); + let title = state.pending.windows[0].title.as_ptr(); + assert!(control.poll().snapshot.is_none()); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: 1 }, + ); + let first = control.poll().snapshot.unwrap(); + assert_eq!(first.windows.as_ptr(), windows); + assert_eq!(first.windows[0].app_id.as_ptr(), app_id); + assert_eq!(first.windows[0].title.as_ptr(), title); + for (begin, end, id) in [(2, 99, 20), (3, 3, 30)] { + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotBegin { serial: begin }, + ); + dispatch_event(&control, &mut state, &queue, window_event(id, 0)); + assert!(control.poll().snapshot.is_none()); + dispatch_event( + &control, + &mut state, + &queue, + ferese_shell_v1::Event::SnapshotEnd { serial: end }, + ); + let next = control.poll().snapshot; + if begin == end { + let next = next.unwrap(); + assert_eq!(next.windows.len(), 1); + assert_eq!(next.windows[0].id, id); + } else { + assert!(next.is_none(), "mismatched serials must not publish"); + } + } + assert_eq!(first.windows.len(), 1); + assert_eq!(first.windows[0].id, 10); + assert_eq!(first.windows[0].app_id, "dev.ferese.Test"); + assert_eq!(first.windows[0].title, "Same title"); + } + #[test] fn window_id_words_round_trip_without_truncation() { for (id, hi, lo) in [