Avoid capture busy-waiting and redundant config and DMA-BUF checks

This commit is contained in:
Samuel Onoja committed 2026-10-25 15:37:52 +01:00
1 parent 979afbd213
commit 392e9cfeb0
6 files changed
+246 -48

No files matched your search

+106 -15
View File
@@ -23,11 +23,19 @@ const KEYS: [&str; 7] = [
];
#[derive(Clone)]
pub(crate) struct Lockdown(Arc<RwLock<[bool; 7]>>);
pub(crate) struct Lockdown(Arc<RwLock<Policy>>);
struct Policy {
values: [bool; 7],
source: Option<String>,
}
impl Lockdown {
pub(crate) fn new() -> Self {
Self(Arc::new(RwLock::new(load().unwrap_or_default())))
Self(Arc::new(RwLock::new(Policy {
values: load().unwrap_or_default(),
source: None,
})))
}
pub(crate) async fn watch(self, connection: Connection) {
@@ -36,17 +44,22 @@ impl Lockdown {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let mut current = self.0.write().await;
let Some(next) = load() else { continue };
if *current == next {
let Some(path) = ferese_config::config_path() else {
continue;
};
let Some(next) = load_changed(&path, &mut current.source) else {
continue;
};
if current.values == next {
continue;
}
let changed = KEYS
.iter()
.enumerate()
.filter(|(index, _)| current[*index] != next[*index])
.filter(|(index, _)| current.values[*index] != next[*index])
.map(|(index, key)| (*key, Value::from(next[index])))
.collect::<HashMap<_, _>>();
*current = next;
current.values = next;
drop(current);
let _ = zbus::fdo::Properties::properties_changed(
&emitter,
@@ -72,7 +85,9 @@ impl Lockdown {
crate::backend::authorize(connection, &header).await?;
let mut current = self.0.write().await;
store(index, value).map_err(zbus::fdo::Error::Failed)?;
current[index] = value;
current.values[index] = value;
// Restoring a previously observed file must undo this write too.
current.source = None;
Ok(())
}
}
@@ -81,7 +96,7 @@ impl Lockdown {
impl Lockdown {
#[zbus(property, name = "disable-printing")]
async fn disable_printing(&self) -> bool {
self.0.read().await[0]
self.0.read().await.values[0]
}
#[zbus(property, name = "disable-printing")]
@@ -96,7 +111,7 @@ impl Lockdown {
#[zbus(property, name = "disable-save-to-disk")]
async fn disable_save_to_disk(&self) -> bool {
self.0.read().await[1]
self.0.read().await.values[1]
}
#[zbus(property, name = "disable-save-to-disk")]
@@ -111,7 +126,7 @@ impl Lockdown {
#[zbus(property, name = "disable-application-handlers")]
async fn disable_application_handlers(&self) -> bool {
self.0.read().await[2]
self.0.read().await.values[2]
}
#[zbus(property, name = "disable-application-handlers")]
@@ -126,7 +141,7 @@ impl Lockdown {
#[zbus(property, name = "disable-location")]
async fn disable_location(&self) -> bool {
self.0.read().await[3]
self.0.read().await.values[3]
}
#[zbus(property, name = "disable-location")]
@@ -141,7 +156,7 @@ impl Lockdown {
#[zbus(property, name = "disable-camera")]
async fn disable_camera(&self) -> bool {
self.0.read().await[4]
self.0.read().await.values[4]
}
#[zbus(property, name = "disable-camera")]
@@ -156,7 +171,7 @@ impl Lockdown {
#[zbus(property, name = "disable-microphone")]
async fn disable_microphone(&self) -> bool {
self.0.read().await[5]
self.0.read().await.values[5]
}
#[zbus(property, name = "disable-microphone")]
@@ -171,7 +186,7 @@ impl Lockdown {
#[zbus(property, name = "disable-sound-output")]
async fn disable_sound_output(&self) -> bool {
self.0.read().await[6]
self.0.read().await.values[6]
}
#[zbus(property, name = "disable-sound-output")]
@@ -205,6 +220,21 @@ fn load() -> Option<[bool; 7]> {
}
fn load_path(path: &Path) -> Option<[bool; 7]> {
values(&ferese_config::Document::parse(&read_source(path)?).ok()?)
}
fn load_changed(path: &Path, previous: &mut Option<String>) -> Option<[bool; 7]> {
let source = read_source(path)?;
if previous.as_ref() == Some(&source) {
return None;
}
// Compare contents so atomic saves and edits with unchanged timestamps work.
// Remember invalid edits too, while retaining the last valid policy.
*previous = Some(source);
values(&ferese_config::Document::parse(previous.as_deref()?).ok()?)
}
fn read_source(path: &Path) -> Option<String> {
let mut source = String::new();
match std::fs::File::open(path) {
Ok(file) => {
@@ -218,7 +248,7 @@ fn load_path(path: &Path) -> Option<[bool; 7]> {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return None,
}
values(&ferese_config::Document::parse(&source).ok()?)
Some(source)
}
fn store(index: usize, value: bool) -> Result<(), String> {
@@ -246,6 +276,67 @@ fn store_path(path: &Path, index: usize, value: bool) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn polling_reloads_only_changed_contents_and_recovers_after_invalid_edits() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let mut source = None;
assert_eq!(load_changed(&path, &mut source), Some([false; 7]));
assert_eq!(load_changed(&path, &mut source), None);
store_path(&path, 4, true).unwrap();
assert!(load_changed(&path, &mut source).unwrap()[4]);
assert_eq!(load_changed(&path, &mut source), None);
for invalid in ["portals {", "portals { lockdown { disable-camera 1; }; }"] {
std::fs::write(&path, invalid).unwrap();
assert_eq!(load_changed(&path, &mut source), None);
assert_eq!(source.as_deref(), Some(invalid));
assert_eq!(load_changed(&path, &mut source), None);
}
// Replacing the file must recover even after a malformed edit.
let replacement = directory.path().join("replacement.kdl");
std::fs::write(
&replacement,
"portals { lockdown { disable-camera #true; }; }",
)
.unwrap();
std::fs::rename(replacement, &path).unwrap();
assert!(load_changed(&path, &mut source).unwrap()[4]);
std::fs::remove_file(&path).unwrap();
assert_eq!(load_changed(&path, &mut source), Some([false; 7]));
store_path(&path, 4, true).unwrap();
assert!(load_changed(&path, &mut source).unwrap()[4]);
}
#[test]
fn polling_detects_same_size_edits_and_retries_failed_reads() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let mut source = None;
std::fs::write(&path, "portals { lockdown { disable-camera #true ; }; }").unwrap();
assert!(load_changed(&path, &mut source).unwrap()[4]);
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
std::fs::write(&path, "portals { lockdown { disable-camera #false; }; }").unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(modified)
.unwrap();
assert!(!load_changed(&path, &mut source).unwrap()[4]);
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let previous = source.clone();
assert_eq!(load_changed(&path, &mut source), None);
assert_eq!(source, previous);
std::fs::remove_dir(&path).unwrap();
store_path(&path, 4, true).unwrap();
assert!(load_changed(&path, &mut source).unwrap()[4]);
}
#[test]
fn policy_save_respects_other_config_writers_and_preserves_their_changes() {
let directory = tempfile::tempdir().unwrap();
+89 -14
View File
@@ -6,7 +6,7 @@ use std::ptr::NonNull;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use std::time::{Duration, Instant};
use pipewire as pw;
use pipewire::properties::properties;
@@ -537,16 +537,18 @@ pub fn run(
}
})
.register()?;
let capture_io = capture.clone();
// Readiness wakes the loop; dispatch once at its top, alongside deadlines
// and PipeWire callbacks, instead of draining the same queue twice.
let _capture_io = mainloop.loop_().add_io(
capture.borrow().poll_fd()?,
spa::support::system::IoFlags::IN,
move |_| capture_io.borrow_mut().dispatch(),
|_| {},
);
let _cancel_io =
mainloop
.loop_()
.add_io(cancel_read, spa::support::system::IoFlags::IN, |_| {});
let frame_timer = mainloop.loop_().add_timer(|_| {});
let advertised = formats(size, &rate, dma);
let mut parameters: Vec<_> = advertised
.iter()
@@ -602,27 +604,100 @@ pub fn run(
let deadline = next_frame.max(data.borrow().next_capture);
if can_capture && Instant::now() >= deadline {
stream.trigger_process()?;
next_frame = Instant::now() + rate.interval();
next_frame = next_frame_deadline(deadline, Instant::now(), rate.interval());
}
let timeout = if can_capture {
pw::loop_::Timeout::Finite(
next_frame
.max(data.borrow().next_capture)
.saturating_duration_since(Instant::now()),
)
} else {
pw::loop_::Timeout::Infinite
};
mainloop.loop_().iterate(timeout);
update_frame_timer(
&frame_timer,
can_capture.then(|| next_frame.max(data.borrow().next_capture)),
)?;
mainloop.loop_().iterate(pw::loop_::Timeout::Infinite);
}
let _ = stream.disconnect();
Ok(())
}
fn next_frame_deadline(deadline: Instant, now: Instant, interval: Duration) -> Instant {
let next = deadline + interval;
if next > now {
// Preserve cadence when a timer wakes slightly late.
next
} else {
// Resume after a pause or slow frame without a burst of catch-up frames.
now + interval
}
}
fn update_frame_timer(
timer: &pw::loop_::TimerSource<'_>,
deadline: Option<Instant>,
) -> Result<(), spa::utils::result::Error> {
// Loop timeouts truncate to milliseconds and spin near a frame deadline.
// A timer preserves sub-millisecond waits without changing frame pacing.
let delay = deadline.map(|deadline| {
deadline
.saturating_duration_since(Instant::now())
// Zero disarms a PipeWire timer; an overdue frame must wake instead.
.max(Duration::from_nanos(1))
});
timer.update_timer(delay, None).into_result()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_clock_preserves_cadence_but_does_not_catch_up_after_stalls() {
let start = Instant::now();
let interval = Duration::from_nanos(16_666_667);
let mut deadline = start;
for frame in 1..=240 {
deadline =
next_frame_deadline(deadline, deadline + Duration::from_micros(200), interval);
assert_eq!(deadline, start + interval * frame);
}
for delay in [interval, Duration::from_secs(2)] {
let resumed = deadline + delay;
assert_eq!(
next_frame_deadline(deadline, resumed, interval),
resumed + interval
);
}
}
#[test]
fn frame_timer_wakes_once_for_due_frames_and_disarms_while_paused() {
let mainloop = pw::main_loop::MainLoopRc::new(None).unwrap();
let expirations = Rc::new(std::cell::Cell::new(0));
let received = expirations.clone();
let timer = mainloop.loop_().add_timer(move |count| {
received.set(received.get() + count);
});
for delay in [Duration::ZERO, Duration::from_micros(100)] {
update_frame_timer(&timer, Some(Instant::now() + delay)).unwrap();
let expected = expirations.get() + 1;
let limit = Instant::now() + Duration::from_secs(1);
while expirations.get() < expected && Instant::now() < limit {
mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(10)));
}
assert_eq!(expirations.get(), expected);
mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(5)));
assert_eq!(expirations.get(), expected, "frame timer must be one-shot");
}
update_frame_timer(&timer, Some(Instant::now())).unwrap();
update_frame_timer(&timer, None).unwrap();
let previous = expirations.get();
mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(5)));
assert_eq!(expirations.get(), previous, "paused capture must not wake");
}
#[test]
fn buffer_types_intersect_obs_masks_in_either_negotiation_order() {
// PipeWire expands OBS's MemPtr bit to also permit MemFd transport.