Avoid idle clock and screenshot polling

Update visible shell clocks at their displayed time boundaries and receive recorder state through events, with elapsed updates only while recording. Arm screenshot expiry at the earliest pending deadline and cancel abandoned requests when their IPC client disconnects.

Cache desktop clock formats across shell updates. Transfer owned screenshot readback pixels to the encoder and borrow the canvas for PNG encoding; both paths previously copied a full image. Close the recorder's stdout reader before waiting for its child when a status line is rejected.

Validation: 910 Rust tests passed in the release workspace suite, with 44 ignored. The six screenshot-script tests passed. A private nested release test passed with a visible desktop clock, notification and modal animations, and three real PNG captures. The recorder shutdown and readback ownership regressions failed before their fixes and pass afterwards.

The isolated clock settings check dropped from 30,000 allocations to zero over 10,000 rebuilds. Borrowing the PNG canvas removed 33,177,600 allocated bytes for a 3840x2160 capture while producing identical PNG bytes.

Direct DRM and laptop power checks remain pending.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-04 13:01:52 +01:00
1 parent 49e54f9a63
commit 90e5c3543d
7 files changed
+1562 -161

No files matched your search

+323 -64
View File
@@ -2,10 +2,9 @@
//! Encoding and portal negotiation run in the separate ferese-record process.
use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{ChildStdin, Command, Stdio};
use std::sync::mpsc;
use std::sync::mpsc::Receiver;
use std::time::Instant;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::Deserialize;
@@ -19,21 +18,40 @@ pub enum State {
Saved(PathBuf),
Error(String),
}
#[derive(Deserialize)]
struct Update {
#[derive(Clone, Debug, Deserialize)]
pub struct Update {
state: String,
message: Option<String>,
path: Option<PathBuf>,
}
enum Event {
#[derive(Clone, Debug)]
pub enum Event {
Update(Update),
Exited,
}
#[derive(Clone)]
struct Events(Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Event>>>);
impl std::hash::Hash for Events {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
std::ptr::hash(Arc::as_ptr(&self.0), state);
}
}
impl Events {
fn stream(&self) -> impl cosmic::iced::futures::Stream<Item = Event> + use<> {
cosmic::iced::futures::stream::unfold(self.0.clone(), |receiver| async move {
let event = receiver.lock().await.recv().await?;
Some((event, receiver))
})
}
}
#[derive(Default)]
pub struct Recorder {
pub state: State,
control: Option<ChildStdin>,
updates: Option<Receiver<Event>>,
updates: Option<Events>,
}
impl Recorder {
pub fn label(&self) -> &'static str {
@@ -76,32 +94,11 @@ impl Recorder {
{
Ok(mut child) => {
self.control = child.stdin.take();
let output = child.stdout.take().unwrap();
let (send, receive) = mpsc::sync_channel(8);
self.updates = Some(receive);
let (send, receive) = tokio::sync::mpsc::channel(8);
self.updates = Some(Events(Arc::new(tokio::sync::Mutex::new(receive))));
self.state = State::Selecting;
std::thread::spawn(move || {
let mut output = BufReader::new(output);
loop {
let mut line = String::new();
match output.by_ref().take(65537).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if line.len() > 65536 => break,
Ok(_) => {
if let Ok(update) = serde_json::from_str::<Update>(&line)
&& send.send(Event::Update(update)).is_err()
{
break;
}
}
}
}
let _ = child.wait();
let _ = send.send(Event::Exited);
});
std::thread::spawn(move || read_events(child, send));
}
Err(error) => {
let message = format!("Cannot start recorder: {error}");
@@ -119,47 +116,101 @@ impl Recorder {
}
}
pub fn poll(&mut self) {
let mut done = false;
if let Some(updates) = &self.updates {
while let Ok(event) = updates.try_recv() {
match event {
Event::Update(update) => match update.state.as_str() {
"selecting" if self.control.is_some() => self.state = State::Selecting,
"recording" if self.control.is_some() => self.state = State::Recording(Instant::now()),
"saving" => self.state = State::Saving,
"saved" => {
self.state = update
.path
.map(State::Saved)
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
}
"cancelled" => self.state = State::Idle,
"error" => {
self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into()))
}
_ => (),
},
Event::Exited => {
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
self.state = State::Error("The recorder stopped unexpectedly".into());
}
done = true;
}
}
}
pub fn subscription(&self) -> cosmic::iced::Subscription<Event> {
self.updates
.as_ref()
.map_or_else(cosmic::iced::Subscription::none, |events| {
cosmic::iced::Subscription::run_with(events.clone(), Events::stream)
})
}
pub fn elapsed_subscription(&self) -> cosmic::iced::Subscription<()> {
fn stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
let deadline = *deadline;
cosmic::iced::futures::stream::once(async move {
tokio::time::sleep_until(deadline.into()).await;
})
}
if done {
self.elapsed_deadline(Instant::now())
.map_or_else(cosmic::iced::Subscription::none, |deadline| {
cosmic::iced::Subscription::run_with(deadline, stream)
})
}
fn elapsed_deadline(&self, now: Instant) -> Option<Instant> {
let State::Recording(start) = self.state else {
return None;
};
Some(start + Duration::from_secs(now.saturating_duration_since(start).as_secs() + 1))
}
pub fn handle(&mut self, event: Event) {
if self.apply(event) {
match &self.state {
State::Saved(path) => notify("Recording saved", path.display().to_string()),
State::Error(message) => notify("Recording failed", message.clone()),
_ => (),
}
self.control.take();
self.updates.take();
}
}
fn apply(&mut self, event: Event) -> bool {
match event {
Event::Update(update) => match update.state.as_str() {
"selecting" if self.control.is_some() => self.state = State::Selecting,
"recording" if self.control.is_some() => {
if !matches!(self.state, State::Recording(_)) {
self.state = State::Recording(Instant::now());
}
}
"saving" => self.state = State::Saving,
"saved" => {
self.state = update
.path
.map(State::Saved)
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
}
"cancelled" => self.state = State::Idle,
"error" => self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into())),
_ => (),
},
Event::Exited => {
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
self.state = State::Error("The recorder stopped unexpectedly".into());
}
self.control.take();
self.updates.take();
return true;
}
}
false
}
}
fn read_events(mut child: Child, send: tokio::sync::mpsc::Sender<Event>) {
let mut output = BufReader::new(child.stdout.take().expect("piped recorder output"));
loop {
let mut line = String::new();
match output.by_ref().take(65537).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if line.len() > 65536 => break,
Ok(_) => {
if let Ok(update) = serde_json::from_str::<Update>(&line)
&& send.blocking_send(Event::Update(update)).is_err()
{
break;
}
}
}
}
// A rejected status line may leave the child blocked writing into stdout.
// Close our reader before waiting so that writer can observe the disconnect.
drop(output);
let _ = child.wait();
let _ = send.blocking_send(Event::Exited);
}
// Use the normal notification center for results, without an extra recording menu.
@@ -195,3 +246,211 @@ fn notify(title: &'static str, body: String) {
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::futures::{FutureExt, StreamExt};
#[test]
fn oversized_status_closes_stdout_before_waiting_for_exit() {
let child = Command::new("head")
.args(["-c", "16777216", "/dev/zero"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
let (send, mut receive) = tokio::sync::mpsc::channel(8);
let (done_send, done_receive) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
read_events(child, send);
done_send.send(()).unwrap();
});
let done = done_receive.recv_timeout(Duration::from_secs(2));
if done.is_err() {
// Reap a stuck test child before reporting a failure. The reader
// still owns Child, so this PID cannot have been reused.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
}
worker.join().unwrap();
assert!(
done.is_ok(),
"reader kept the output pipe open while waiting for a blocked writer"
);
assert!(matches!(receive.try_recv(), Ok(Event::Exited)));
}
fn update(state: &str, path: Option<PathBuf>) -> Event {
Event::Update(Update {
state: state.into(),
message: None,
path,
})
}
fn session() -> (Recorder, std::process::Child, tokio::sync::mpsc::Sender<Event>) {
// A real control pipe lets stop() exercise EOF without a recorder,
// portal, encoder, or D-Bus service.
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let (send, receive) = tokio::sync::mpsc::channel(8);
let recorder = Recorder {
state: State::Selecting,
control: child.stdin.take(),
updates: Some(Events(Arc::new(tokio::sync::Mutex::new(receive)))),
};
(recorder, child, send)
}
#[test]
fn active_recording_events_stop_by_eof_and_keep_terminal_result() {
let (mut recorder, mut child, send) = session();
assert_eq!(recorder.subscription().units(), 1);
assert_eq!(recorder.elapsed_subscription().units(), 0);
recorder.apply(update("selecting", None));
assert!(matches!(recorder.state, State::Selecting));
recorder.apply(update("recording", None));
let State::Recording(start) = recorder.state else {
panic!("recording event was lost")
};
assert_eq!(recorder.elapsed_subscription().units(), 1);
recorder.apply(update("recording", None));
assert!(matches!(recorder.state, State::Recording(same) if same == start));
recorder.stop();
assert!(matches!(recorder.state, State::Saving));
assert!(recorder.control.is_none());
assert!(recorder.busy());
assert_eq!(recorder.elapsed_subscription().units(), 0);
assert!(child.wait().unwrap().success());
recorder.apply(update("selecting", None));
recorder.apply(update("recording", None));
assert!(matches!(recorder.state, State::Saving));
recorder.apply(update("saving", None));
recorder.apply(update("saved", Some("display.webm".into())));
assert!(recorder.apply(Event::Exited));
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("display.webm")));
assert!(!recorder.busy());
assert_eq!(recorder.subscription().units(), 0);
assert!(send.try_send(Event::Exited).is_err());
}
#[test]
fn cancelling_selection_uses_eof_and_leaves_no_elapsed_or_event_timer() {
let (mut recorder, mut child, send) = session();
recorder.stop();
assert!(matches!(recorder.state, State::Saving));
assert!(child.wait().unwrap().success());
recorder.apply(update("cancelled", None));
assert!(recorder.apply(Event::Exited));
assert!(matches!(recorder.state, State::Idle));
assert_eq!(recorder.subscription().units(), 0);
assert_eq!(recorder.elapsed_subscription().units(), 0);
assert!(send.try_send(Event::Exited).is_err());
}
#[test]
fn elapsed_deadlines_only_run_while_recording_and_align_to_start() {
let start = Instant::now();
let mut recorder = Recorder::default();
for state in [
State::Idle,
State::Selecting,
State::Saving,
State::Saved("video.webm".into()),
State::Error("failed".into()),
] {
recorder.state = state;
assert_eq!(recorder.elapsed_deadline(start), None);
}
recorder.state = State::Recording(start);
assert_eq!(
recorder.elapsed_deadline(start + Duration::from_millis(999)),
Some(start + Duration::from_secs(1))
);
assert_eq!(
recorder.elapsed_deadline(start + Duration::from_secs(1)),
Some(start + Duration::from_secs(2))
);
assert_eq!(
recorder.elapsed_deadline(start + Duration::from_millis(5200)),
Some(start + Duration::from_secs(6))
);
}
#[test]
fn final_recording_results_survive_exit_and_release_session() {
for terminal in ["saved", "cancelled", "error"] {
let mut recorder = Recorder {
state: State::Saving,
..Recorder::default()
};
let (send, receive) = tokio::sync::mpsc::channel(8);
recorder.updates = Some(Events(Arc::new(tokio::sync::Mutex::new(receive))));
recorder.apply(update(terminal, Some("video.webm".into())));
assert!(recorder.busy());
assert!(recorder.apply(Event::Exited));
assert!(!recorder.busy());
match terminal {
"saved" => {
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("video.webm")))
}
"cancelled" => assert!(matches!(recorder.state, State::Idle)),
_ => assert!(matches!(recorder.state, State::Error(ref message) if message == "Recording failed")),
}
assert!(send.try_send(Event::Exited).is_err());
}
}
#[test]
fn missing_path_and_unexpected_exit_remain_errors() {
let mut recorder = Recorder::default();
recorder.apply(update("saved", None));
assert!(matches!(recorder.state, State::Error(ref message) if message == "Recorder returned no saved file"));
for state in [State::Selecting, State::Recording(Instant::now()), State::Saving] {
recorder.state = state;
recorder.apply(Event::Exited);
assert!(
matches!(recorder.state, State::Error(ref message) if message == "The recorder stopped unexpectedly")
);
}
}
#[test]
fn late_start_events_cannot_restart_stopped_recorder() {
let mut recorder = Recorder {
state: State::Saving,
..Recorder::default()
};
recorder.apply(update("selecting", None));
recorder.apply(update("recording", None));
assert!(matches!(recorder.state, State::Saving));
}
#[test]
fn event_stream_wakes_without_polling_and_keeps_bounded_order() {
let (send, receive) = tokio::sync::mpsc::channel(8);
let events = Events(Arc::new(tokio::sync::Mutex::new(receive)));
let stream = events.stream();
cosmic::iced::futures::pin_mut!(stream);
assert!(stream.next().now_or_never().is_none());
for _ in 0..8 {
send.try_send(update("saving", None)).unwrap();
}
assert!(send.try_send(Event::Exited).is_err());
for _ in 0..8 {
assert!(
matches!(stream.next().now_or_never(), Some(Some(Event::Update(Update { state, .. }))) if state == "saving")
);
}
send.try_send(Event::Exited).unwrap();
assert!(matches!(stream.next().now_or_never(), Some(Some(Event::Exited))));
drop(send);
assert!(matches!(stream.next().now_or_never(), Some(None)));
}
}