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:
1 parent
49e54f9a63
commit
90e5c3543d
7 files changed
+1562
-161
No files matched your search
@@ -345,6 +345,7 @@ enum RequestState {
|
||||
}
|
||||
|
||||
struct Request {
|
||||
owner: u64,
|
||||
response_id: u64,
|
||||
reply: ReplySender<Response>,
|
||||
state: RequestState,
|
||||
@@ -374,6 +375,8 @@ pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub(crate) struct Coordinator {
|
||||
requests: std::collections::HashMap<u64, Request>,
|
||||
next_id: u64,
|
||||
deadline_changed: Option<Box<dyn Fn(Option<Instant>)>>,
|
||||
notified_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
@@ -381,11 +384,29 @@ impl Coordinator {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) fn set_deadline_observer(&mut self, observer: impl Fn(Option<Instant>) + 'static) {
|
||||
self.notified_deadline = self.next_deadline();
|
||||
observer(self.notified_deadline);
|
||||
self.deadline_changed = Some(Box::new(observer));
|
||||
}
|
||||
|
||||
pub(crate) fn admit(
|
||||
&mut self,
|
||||
owner: u64,
|
||||
response_id: u64,
|
||||
reply: ReplySender<Response>,
|
||||
specs: Vec<PartSpec>,
|
||||
) -> Result<u64, String> {
|
||||
self.admit_at(owner, response_id, reply, specs, Instant::now())
|
||||
}
|
||||
|
||||
fn admit_at(
|
||||
&mut self,
|
||||
owner: u64,
|
||||
response_id: u64,
|
||||
reply: ReplySender<Response>,
|
||||
specs: Vec<PartSpec>,
|
||||
now: Instant,
|
||||
) -> Result<u64, String> {
|
||||
if specs.is_empty() {
|
||||
return Err("No outputs are enabled".into());
|
||||
@@ -416,14 +437,16 @@ impl Coordinator {
|
||||
self.requests.insert(
|
||||
id,
|
||||
Request {
|
||||
owner,
|
||||
response_id,
|
||||
reply,
|
||||
state: RequestState::Collecting,
|
||||
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
|
||||
received: 0,
|
||||
deadline: Instant::now() + REQUEST_TIMEOUT,
|
||||
deadline: now + REQUEST_TIMEOUT,
|
||||
},
|
||||
);
|
||||
self.notify_deadline_change();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -455,36 +478,22 @@ impl Coordinator {
|
||||
}
|
||||
|
||||
let frame = match result {
|
||||
Ok(ref buffer) => {
|
||||
let mut pixels = Vec::new();
|
||||
if pixels.try_reserve_exact(buffer.pixels.len()).is_err() {
|
||||
None
|
||||
} else {
|
||||
pixels.extend_from_slice(&buffer.pixels);
|
||||
Some(OutputFrame {
|
||||
preserve_alpha: spec.preserve_alpha,
|
||||
width: buffer.width,
|
||||
height: buffer.height,
|
||||
stride: buffer.stride,
|
||||
pixels,
|
||||
transform: spec.transform,
|
||||
location: spec.location,
|
||||
scale: spec.scale,
|
||||
logical_width: spec.logical_width,
|
||||
logical_height: spec.logical_height,
|
||||
})
|
||||
}
|
||||
Ok(buffer) => OutputFrame {
|
||||
preserve_alpha: spec.preserve_alpha,
|
||||
width: buffer.width,
|
||||
height: buffer.height,
|
||||
stride: buffer.stride,
|
||||
pixels: buffer.pixels,
|
||||
transform: spec.transform,
|
||||
location: spec.location,
|
||||
scale: spec.scale,
|
||||
logical_width: spec.logical_width,
|
||||
logical_height: spec.logical_height,
|
||||
},
|
||||
Err(error) => {
|
||||
self.reject_inner(request, &error);
|
||||
return Action::None;
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let Some(frame) = frame else {
|
||||
let error = match result {
|
||||
Err(error) => error,
|
||||
Ok(_) => "Screenshot capture could not be allocated".to_string(),
|
||||
};
|
||||
self.reject_inner(request, &error);
|
||||
return Action::None;
|
||||
};
|
||||
|
||||
let Some(entry) = self.requests.get_mut(&request) else {
|
||||
@@ -516,7 +525,7 @@ impl Coordinator {
|
||||
Err(_) => Action::None,
|
||||
};
|
||||
}
|
||||
let entry = self.requests.remove(&request).expect("checked above");
|
||||
let entry = self.remove_request(request).expect("checked above");
|
||||
match result {
|
||||
Ok(path) => {
|
||||
reply(&entry, Ok(&path));
|
||||
@@ -536,8 +545,7 @@ impl Coordinator {
|
||||
// Answers every request that outlived its deadline. The returned ids let the
|
||||
// caller drop readbacks that would otherwise publish into a request that no
|
||||
// longer exists.
|
||||
pub(crate) fn expire(&mut self) -> Vec<u64> {
|
||||
let now = Instant::now();
|
||||
pub(crate) fn expire_at(&mut self, now: Instant) -> Vec<u64> {
|
||||
let expired: Vec<u64> = self
|
||||
.requests
|
||||
.iter()
|
||||
@@ -550,13 +558,22 @@ impl Coordinator {
|
||||
expired
|
||||
}
|
||||
|
||||
// The remaining time before the earliest deadline, so the caller can sleep
|
||||
// exactly that long instead of polling on a fixed tick.
|
||||
pub(crate) fn next_deadline(&self) -> Option<Duration> {
|
||||
self.requests
|
||||
.values()
|
||||
.map(|entry| entry.deadline.saturating_duration_since(Instant::now()))
|
||||
.min()
|
||||
// Absolute deadlines do not drift when unrelated work wakes the loop.
|
||||
pub(crate) fn next_deadline(&self) -> Option<Instant> {
|
||||
self.requests.values().map(|entry| entry.deadline).min()
|
||||
}
|
||||
|
||||
pub(crate) fn terminate_owner(&mut self, owner: u64) -> Vec<u64> {
|
||||
let ids: Vec<u64> = self
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.owner == owner)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
for id in &ids {
|
||||
self.reject_inner(*id, "Screenshot cancelled: the client disconnected");
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub(crate) fn terminate_all(&mut self) -> Vec<u64> {
|
||||
@@ -572,10 +589,28 @@ impl Coordinator {
|
||||
}
|
||||
|
||||
fn reject_inner(&mut self, request: u64, error: &str) {
|
||||
if let Some(entry) = self.requests.remove(&request) {
|
||||
if let Some(entry) = self.remove_request(request) {
|
||||
reply(&entry, Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
// All terminal paths pass through here, including readback/encode failure,
|
||||
// owner closure, cancellation and timeout, so none can leave a stale timer.
|
||||
fn remove_request(&mut self, request: u64) -> Option<Request> {
|
||||
let entry = self.requests.remove(&request)?;
|
||||
self.notify_deadline_change();
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
fn notify_deadline_change(&mut self) {
|
||||
let deadline = self.next_deadline();
|
||||
if deadline != self.notified_deadline {
|
||||
self.notified_deadline = deadline;
|
||||
if let Some(observer) = &self.deadline_changed {
|
||||
observer(deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(entry: &Request, result: Result<&Path, &str>) {
|
||||
@@ -918,7 +953,7 @@ mod completion {
|
||||
let (reply, received) = sync_channel(8);
|
||||
let mut coordinator = Coordinator::new();
|
||||
coordinator
|
||||
.admit(42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect())
|
||||
.admit(1, 42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect())
|
||||
.expect("admitted");
|
||||
Self { coordinator, received }
|
||||
}
|
||||
@@ -928,6 +963,23 @@ mod completion {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_readback_moves_its_pixels_to_the_encoder() {
|
||||
let mut harness = Harness::new(1);
|
||||
let id = harness.coordinator.next_id;
|
||||
let mut captured = buffer(2, 2);
|
||||
captured.pixels.fill(137);
|
||||
let allocation = captured.pixels.as_ptr();
|
||||
|
||||
let Action::Encode { frames, .. } = harness.coordinator.on_part(id, 0, Ok(captured)) else {
|
||||
panic!("completed readback was not queued for encoding");
|
||||
};
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pixels.as_ptr(), allocation, "readback pixels were copied");
|
||||
assert_eq!(frames[0].pixels, vec![137; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_failure_terminates_the_request_with_an_error() {
|
||||
let mut harness = Harness::new(2);
|
||||
@@ -1105,7 +1157,7 @@ mod completion {
|
||||
let id = harness.coordinator.next_id;
|
||||
harness.coordinator.requests.get_mut(&id).expect("tracked").deadline = Instant::now() - Duration::from_secs(1);
|
||||
|
||||
assert_eq!(harness.coordinator.expire(), vec![id]);
|
||||
assert_eq!(harness.coordinator.expire_at(Instant::now()), vec![id]);
|
||||
let response = harness.response().expect("the caller is answered");
|
||||
assert_eq!(response.error.as_ref().unwrap().code, "screenshot_failed");
|
||||
assert!(
|
||||
@@ -1126,10 +1178,14 @@ mod completion {
|
||||
fn a_live_request_is_not_expired_early() {
|
||||
let mut harness = Harness::new(1);
|
||||
let id = harness.coordinator.next_id;
|
||||
assert!(harness.coordinator.expire().is_empty(), "not yet due");
|
||||
assert!(harness.coordinator.expire_at(Instant::now()).is_empty(), "not yet due");
|
||||
assert!(harness.coordinator.is_live(id));
|
||||
assert!(harness.response().is_none(), "still unanswered, not failed");
|
||||
let remaining = harness.coordinator.next_deadline().expect("still armed");
|
||||
let remaining = harness
|
||||
.coordinator
|
||||
.next_deadline()
|
||||
.expect("still armed")
|
||||
.saturating_duration_since(Instant::now());
|
||||
assert!(
|
||||
remaining <= REQUEST_TIMEOUT && remaining > REQUEST_TIMEOUT - Duration::from_secs(1),
|
||||
"the timer re-arms to roughly the full timeout, got {remaining:?}"
|
||||
@@ -1142,17 +1198,17 @@ mod completion {
|
||||
let mut coordinator = Coordinator::new();
|
||||
for _ in 0..MAX_OUTSTANDING {
|
||||
coordinator
|
||||
.admit(42, reply.clone(), vec![spec(2, 2)])
|
||||
.admit(1, 42, reply.clone(), vec![spec(2, 2)])
|
||||
.expect("within the request cap");
|
||||
}
|
||||
for entry in coordinator.requests.values_mut() {
|
||||
entry.deadline = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
assert_eq!(coordinator.expire().len(), MAX_OUTSTANDING);
|
||||
assert_eq!(coordinator.expire_at(Instant::now()).len(), MAX_OUTSTANDING);
|
||||
assert_eq!(coordinator.next_deadline(), None);
|
||||
// The cap is usable again rather than permanently degraded.
|
||||
coordinator
|
||||
.admit(42, reply, vec![spec(2, 2)])
|
||||
.admit(1, 42, reply, vec![spec(2, 2)])
|
||||
.expect("a slot was released");
|
||||
}
|
||||
|
||||
@@ -1162,10 +1218,10 @@ mod completion {
|
||||
let mut coordinator = Coordinator::new();
|
||||
for _ in 0..MAX_OUTSTANDING {
|
||||
coordinator
|
||||
.admit(42, reply.clone(), vec![spec(2, 2)])
|
||||
.admit(1, 42, reply.clone(), vec![spec(2, 2)])
|
||||
.expect("within the request cap");
|
||||
}
|
||||
let overflow = coordinator.admit(42, reply.clone(), vec![spec(2, 2)]);
|
||||
let overflow = coordinator.admit(1, 42, reply.clone(), vec![spec(2, 2)]);
|
||||
assert!(overflow.is_err(), "outstanding requests are bounded");
|
||||
|
||||
let huge = PartSpec {
|
||||
@@ -1176,11 +1232,172 @@ mod completion {
|
||||
};
|
||||
let mut fresh = Coordinator::new();
|
||||
assert!(
|
||||
fresh.admit(42, reply.clone(), vec![huge]).is_err(),
|
||||
fresh.admit(1, 42, reply.clone(), vec![huge]).is_err(),
|
||||
"the byte budget is checked before any readback allocates"
|
||||
);
|
||||
}
|
||||
|
||||
fn observe_deadlines(coordinator: &mut Coordinator) -> std::rc::Rc<std::cell::RefCell<Vec<Option<Instant>>>> {
|
||||
let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let observed = changes.clone();
|
||||
coordinator.set_deadline_observer(move |deadline| observed.borrow_mut().push(deadline));
|
||||
changes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_admission_after_long_idle_and_earliest_removal_rearm_exactly() {
|
||||
let now = Instant::now();
|
||||
let (reply, _received) = sync_channel(8);
|
||||
let mut coordinator = Coordinator::new();
|
||||
let changes = observe_deadlines(&mut coordinator);
|
||||
assert_eq!(&*changes.borrow(), &[None]);
|
||||
|
||||
// An idle period does not establish a polling epoch. Every request gets
|
||||
// exactly the timeout measured from its own admission.
|
||||
let late = now + Duration::from_secs(3600);
|
||||
let first = coordinator
|
||||
.admit_at(1, 42, reply.clone(), vec![spec(2, 2)], late)
|
||||
.unwrap();
|
||||
let second = coordinator
|
||||
.admit_at(2, 43, reply.clone(), vec![spec(2, 2)], late + Duration::from_secs(5))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
changes.borrow().len(),
|
||||
2,
|
||||
"a later request does not rearm the earliest timer"
|
||||
);
|
||||
assert!(coordinator.admit_at(1, 44, reply, Vec::new(), late).is_err());
|
||||
assert_eq!(changes.borrow().len(), 2, "rejected admission does not touch the timer");
|
||||
assert_eq!(coordinator.terminate_owner(1), vec![first]);
|
||||
assert!(coordinator.is_live(second));
|
||||
assert_eq!(coordinator.terminate_owner(99), Vec::<u64>::new());
|
||||
coordinator.reject(second, "encoding queue is full");
|
||||
assert_eq!(
|
||||
&*changes.borrow(),
|
||||
&[
|
||||
None,
|
||||
Some(late + REQUEST_TIMEOUT),
|
||||
Some(late + Duration::from_secs(5) + REQUEST_TIMEOUT),
|
||||
None,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_survives_readback_and_encoding_then_disarms_on_completion() {
|
||||
for result in [Ok(PathBuf::from("/tmp/shot.png")), Err("encoder failed".into())] {
|
||||
let mut harness = Harness::new(2);
|
||||
let changes = observe_deadlines(&mut harness.coordinator);
|
||||
let id = harness.coordinator.next_id;
|
||||
let deadline = harness.coordinator.next_deadline();
|
||||
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
|
||||
assert!(matches!(
|
||||
harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))),
|
||||
Action::Encode { .. }
|
||||
));
|
||||
assert_eq!(&*changes.borrow(), &[deadline], "encoding keeps the original deadline");
|
||||
harness.coordinator.on_encoded(id, result);
|
||||
assert_eq!(&*changes.borrow(), &[deadline, None]);
|
||||
harness.coordinator.on_part(id, 0, Err("late readback".into()));
|
||||
harness.coordinator.on_encoded(id, Err("late encode".into()));
|
||||
assert_eq!(
|
||||
changes.borrow().len(),
|
||||
2,
|
||||
"late results cannot rearm an empty coordinator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_cancellations_disarm_collecting_and_encoding_requests() {
|
||||
for encoding in [false, true] {
|
||||
for cancellation in [
|
||||
"output was removed",
|
||||
"session locked",
|
||||
"privacy changed",
|
||||
"client closed",
|
||||
] {
|
||||
// Topology failures arrive as parts while collecting. Once
|
||||
// encoding begins its snapshotted frames remain valid.
|
||||
if encoding && cancellation == "output was removed" {
|
||||
continue;
|
||||
}
|
||||
let mut harness = Harness::new(1);
|
||||
let changes = observe_deadlines(&mut harness.coordinator);
|
||||
let id = harness.coordinator.next_id;
|
||||
if encoding {
|
||||
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
|
||||
}
|
||||
match cancellation {
|
||||
"output was removed" => {
|
||||
harness.coordinator.on_part(id, 0, Err(cancellation.into()));
|
||||
}
|
||||
"session locked" => {
|
||||
harness.coordinator.terminate_all();
|
||||
}
|
||||
"privacy changed" => {
|
||||
harness.coordinator.terminate_all_with_reason(cancellation);
|
||||
}
|
||||
"client closed" => {
|
||||
harness.coordinator.terminate_owner(1);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(changes.borrow().last(), Some(&None), "{cancellation} must disarm");
|
||||
assert_eq!(changes.borrow().len(), 2);
|
||||
assert!(harness.response().unwrap().error.is_some());
|
||||
assert!(!harness.coordinator.is_live(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_expiry_moves_to_next_request_and_frees_encoding_slots() {
|
||||
let now = Instant::now();
|
||||
let (reply, received) = sync_channel(8);
|
||||
let mut coordinator = Coordinator::new();
|
||||
let changes = observe_deadlines(&mut coordinator);
|
||||
let first = coordinator
|
||||
.admit_at(1, 42, reply.clone(), vec![spec(2, 2)], now)
|
||||
.unwrap();
|
||||
let second = coordinator
|
||||
.admit_at(2, 43, reply.clone(), vec![spec(2, 2)], now + Duration::from_secs(5))
|
||||
.unwrap();
|
||||
coordinator.on_part(first, 0, Ok(buffer(2, 2)));
|
||||
assert!(
|
||||
coordinator
|
||||
.expire_at(now + REQUEST_TIMEOUT - Duration::from_nanos(1))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(coordinator.expire_at(now + REQUEST_TIMEOUT), vec![first]);
|
||||
assert!(coordinator.is_live(second));
|
||||
assert_eq!(
|
||||
coordinator.expire_at(now + REQUEST_TIMEOUT + Duration::from_secs(5)),
|
||||
vec![second]
|
||||
);
|
||||
assert_eq!(
|
||||
&*changes.borrow(),
|
||||
&[
|
||||
None,
|
||||
Some(now + REQUEST_TIMEOUT),
|
||||
Some(now + REQUEST_TIMEOUT + Duration::from_secs(5)),
|
||||
None,
|
||||
]
|
||||
);
|
||||
assert_eq!(received.try_iter().count(), 2, "both timeouts answer once");
|
||||
assert!(matches!(
|
||||
coordinator.on_encoded(first, Ok(PathBuf::from("/tmp/late.png"))),
|
||||
Action::DiscardFile(_)
|
||||
));
|
||||
coordinator
|
||||
.admit_at(3, 44, reply, vec![spec(2, 2)], now + Duration::from_secs(3600))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
changes.borrow().last(),
|
||||
Some(&Some(now + Duration::from_secs(3600) + REQUEST_TIMEOUT))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sink_publishes_once_and_then_rejects_duplicates() {
|
||||
let (sender, receiver) = loop_channel();
|
||||
|
||||
@@ -115,7 +115,7 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err("Screenshot is empty".into());
|
||||
}
|
||||
let image = image::RgbaImage::from_raw(width, height, canvas.pixels.clone())
|
||||
let image = image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice())
|
||||
.ok_or("Screenshot buffer does not match its dimensions")?;
|
||||
let mut out = Vec::new();
|
||||
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
||||
@@ -210,7 +210,11 @@ mod tests {
|
||||
request: 1,
|
||||
frames: vec![frame(4, 3)],
|
||||
};
|
||||
let canvas = compose(&job.frames).unwrap();
|
||||
let mut canvas = compose(&job.frames).unwrap();
|
||||
for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() {
|
||||
pixel[..3].copy_from_slice(&[index as u8, 137, 241]);
|
||||
}
|
||||
|
||||
let png = encode_png(&canvas).expect("png");
|
||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "png signature");
|
||||
|
||||
@@ -218,6 +222,7 @@ mod tests {
|
||||
.expect("decodes back")
|
||||
.to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), (4, 3));
|
||||
assert_eq!(decoded.as_raw(), &canvas.pixels, "encoding changed the pixel data");
|
||||
// The opaque alpha we force is preserved through the round trip.
|
||||
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
|
||||
}
|
||||
|
||||
+187
-29
@@ -1,11 +1,13 @@
|
||||
use std::cell::RefCell;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{env, fs, io, thread};
|
||||
|
||||
use ferese_core::LayoutMode;
|
||||
@@ -13,7 +15,7 @@ use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use ferese_layout::Direction;
|
||||
use serde_json::{Value, json};
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::calloop::{EventLoop, LoopSignal, channel, timer};
|
||||
use smithay::reexports::calloop::{EventLoop, LoopHandle, LoopSignal, RegistrationToken, channel, timer};
|
||||
use smithay::utils::Transform;
|
||||
|
||||
use crate::Ferese;
|
||||
@@ -34,10 +36,6 @@ const _: () = assert!(
|
||||
"the result queue must outsize the job queue plus the in-flight job"
|
||||
);
|
||||
const SWEEP_INTERVAL: Duration = crate::handlers::screenshot_worker::SWEEP_INTERVAL;
|
||||
// Only consulted when no request is outstanding, so it bounds how late a newly
|
||||
// admitted request can be noticed. With a request pending the timer re-arms to
|
||||
// the exact remaining time instead.
|
||||
const DEADLINE_IDLE: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IpcCall {
|
||||
@@ -110,6 +108,11 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
let call = match event {
|
||||
IpcEvent::Call(call) => call,
|
||||
IpcEvent::Closed(owner) => {
|
||||
for request in state.screenshot.terminate_owner(owner) {
|
||||
state
|
||||
.pending_screencopies
|
||||
.retain(|capture| capture.request_id() != Some(request));
|
||||
}
|
||||
let captured = state.input_capture.active();
|
||||
state.input_capture.remove_owner(owner);
|
||||
if captured && !state.input_capture.active() {
|
||||
@@ -191,11 +194,11 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
tracing::warn!(%error, "cannot queue configuration reload");
|
||||
}
|
||||
} else if call.request.command == "screenshot-window" {
|
||||
state.start_window_screenshot(call.request, call.response);
|
||||
state.start_window_screenshot(call.owner, call.request, call.response);
|
||||
} else if call.request.command == "screenshot" {
|
||||
// Deferred: this path answers the caller itself, exactly
|
||||
// once, whenever the request finishes or is terminated.
|
||||
state.start_screenshot(call.request, call.response);
|
||||
state.start_screenshot(call.owner, call.request, call.response);
|
||||
} else {
|
||||
let response = state.handle_ipc_request(call.owner, call.request);
|
||||
let _ = call.response.send(response);
|
||||
@@ -236,23 +239,15 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
timer::TimeoutAction::ToDuration(SWEEP_INTERVAL)
|
||||
})?;
|
||||
|
||||
// Readbacks are published by the render path, so a request whose outputs
|
||||
// are not redrawn would otherwise wait forever. This re-arms to the exact
|
||||
// remaining time whenever a request is outstanding, and idles otherwise.
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(timer::Timer::from_duration(DEADLINE_IDLE), |_, _, state| {
|
||||
// Abandoned requests have already been answered, so their
|
||||
// readbacks are simply dropped: they are not failed, because
|
||||
// that would publish into a request that no longer exists.
|
||||
for request in state.screenshot.expire() {
|
||||
state
|
||||
.pending_screencopies
|
||||
.retain(|capture| capture.request_id() != Some(request));
|
||||
}
|
||||
let next = state.screenshot.next_deadline().unwrap_or(DEADLINE_IDLE);
|
||||
timer::TimeoutAction::ToDuration(next)
|
||||
})?;
|
||||
// Admission and every terminal coordinator transition update this one-shot
|
||||
// source. No deadline source is registered when there are no requests.
|
||||
let deadline_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, now| {
|
||||
for request in state.screenshot.expire_at(now) {
|
||||
state
|
||||
.pending_screencopies
|
||||
.retain(|capture| capture.request_id() != Some(request));
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
|
||||
|
||||
@@ -262,6 +257,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
Ok(ScreenshotInit {
|
||||
parts,
|
||||
worker,
|
||||
deadline_timer,
|
||||
_guard: guard,
|
||||
})
|
||||
}
|
||||
@@ -269,9 +265,62 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
pub(crate) struct ScreenshotInit {
|
||||
pub(crate) parts: PartSender,
|
||||
pub(crate) worker: Worker,
|
||||
pub(crate) deadline_timer: DeadlineTimer<Ferese>,
|
||||
pub(crate) _guard: IpcSocketGuard,
|
||||
}
|
||||
|
||||
// The timer callback clears its registration before expiring requests, allowing
|
||||
// the coordinator observer to arm the next deadline during the same dispatch.
|
||||
// Keeping absolute Instants also avoids pushing a timeout later on each rearm.
|
||||
pub(crate) struct DeadlineTimer<Data: 'static> {
|
||||
handle: LoopHandle<'static, Data>,
|
||||
armed: Rc<RefCell<Option<(Instant, RegistrationToken)>>>,
|
||||
expire: fn(&mut Data, Instant),
|
||||
}
|
||||
|
||||
impl<Data: 'static> DeadlineTimer<Data> {
|
||||
fn new(handle: LoopHandle<'static, Data>, expire: fn(&mut Data, Instant)) -> Self {
|
||||
Self {
|
||||
handle,
|
||||
armed: Rc::new(RefCell::new(None)),
|
||||
expire,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(&self, deadline: Option<Instant>) {
|
||||
if self.armed.borrow().as_ref().map(|(deadline, _)| *deadline) == deadline {
|
||||
return;
|
||||
}
|
||||
if let Some((_, token)) = self.armed.borrow_mut().take() {
|
||||
self.handle.remove(token);
|
||||
}
|
||||
let Some(deadline) = deadline else {
|
||||
return;
|
||||
};
|
||||
let armed = self.armed.clone();
|
||||
let expire = self.expire;
|
||||
// calloop's Timer registers only in its in-memory wheel and always
|
||||
// returns Ok; a failure here would break that infrastructure invariant.
|
||||
let token = self
|
||||
.handle
|
||||
.insert_source(timer::Timer::from_deadline(deadline), move |_, _, state| {
|
||||
armed.borrow_mut().take();
|
||||
expire(state, Instant::now());
|
||||
timer::TimeoutAction::Drop
|
||||
})
|
||||
.expect("could not register screenshot deadline timer");
|
||||
*self.armed.borrow_mut() = Some((deadline, token));
|
||||
}
|
||||
}
|
||||
|
||||
impl<Data: 'static> Drop for DeadlineTimer<Data> {
|
||||
fn drop(&mut self) {
|
||||
if let Some((_, token)) = self.armed.borrow_mut().take() {
|
||||
self.handle.remove(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
|
||||
let active_connections = Arc::new(AtomicUsize::new(0));
|
||||
let connection_ids = AtomicU64::new(1);
|
||||
@@ -777,7 +826,7 @@ impl Ferese {
|
||||
|
||||
// Screenshot replies are deferred: this either answers the caller now, or
|
||||
// hands the reply to the coordinator, which answers exactly once.
|
||||
pub(crate) fn start_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
||||
pub(crate) fn start_screenshot(&mut self, owner: u64, request: Request, response: SyncSender<Response>) {
|
||||
// A macro rather than a closure: the message may be a borrowed str or an
|
||||
// owned String, and Response::error already accepts either.
|
||||
macro_rules! reject {
|
||||
@@ -849,7 +898,7 @@ impl Ferese {
|
||||
// admit stores the reply only on success, so a clone survives the
|
||||
// rejection path and every caller is answered exactly once.
|
||||
let fallback = response.clone();
|
||||
let id = match self.screenshot.admit(request.id, response, specs) {
|
||||
let id = match self.screenshot.admit(owner, request.id, response, specs) {
|
||||
Ok(id) => id,
|
||||
Err(message) => {
|
||||
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", message));
|
||||
@@ -878,7 +927,7 @@ impl Ferese {
|
||||
crate::backends::direct::render_on(self, &outputs);
|
||||
}
|
||||
|
||||
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
|
||||
fn start_window_screenshot(&mut self, owner: u64, request: Request, response: SyncSender<Response>) {
|
||||
let reject = |message: String| {
|
||||
let _ = response.try_send(Response::error(request.id, "window_capture_failed", message));
|
||||
};
|
||||
@@ -936,7 +985,7 @@ impl Ferese {
|
||||
buffer_height: size.h,
|
||||
};
|
||||
let fallback = response.clone();
|
||||
let capture = match self.screenshot.admit(request.id, response, vec![spec]) {
|
||||
let capture = match self.screenshot.admit(owner, request.id, response, vec![spec]) {
|
||||
Ok(capture) => capture,
|
||||
Err(error) => {
|
||||
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", error));
|
||||
@@ -1392,6 +1441,115 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DeadlineState {
|
||||
fired: usize,
|
||||
next: Option<Instant>,
|
||||
timer: Option<DeadlineTimer<DeadlineState>>,
|
||||
}
|
||||
|
||||
fn deadline_loop() -> (EventLoop<'static, DeadlineState>, DeadlineState) {
|
||||
let event_loop = EventLoop::try_new().unwrap();
|
||||
let timer = DeadlineTimer::new(event_loop.handle(), |state: &mut DeadlineState, _| {
|
||||
state.fired += 1;
|
||||
state.timer.as_ref().unwrap().update(state.next.take());
|
||||
});
|
||||
(
|
||||
event_loop,
|
||||
DeadlineState {
|
||||
timer: Some(timer),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_deadline_idle_has_no_timer_and_admission_arms_immediately() {
|
||||
let (mut event_loop, mut state) = deadline_loop();
|
||||
let timer = state.timer.as_ref().unwrap();
|
||||
timer.update(None);
|
||||
assert!(timer.armed.borrow().is_none(), "idle registers no timeout source");
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 0);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(3600);
|
||||
let timer = state.timer.as_ref().unwrap();
|
||||
timer.update(Some(deadline));
|
||||
assert_eq!(
|
||||
timer.armed.borrow().as_ref().map(|(deadline, _)| *deadline),
|
||||
Some(deadline)
|
||||
);
|
||||
timer.update(None);
|
||||
assert!(timer.armed.borrow().is_none());
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_deadline_replacement_and_cancellation_remove_old_sources() {
|
||||
let (mut event_loop, mut state) = deadline_loop();
|
||||
let due = Instant::now() - Duration::from_secs(1);
|
||||
let future = Instant::now() + Duration::from_secs(3600);
|
||||
let timer = state.timer.as_ref().unwrap();
|
||||
timer.update(Some(due));
|
||||
let original = timer.armed.borrow().as_ref().unwrap().1;
|
||||
timer.update(Some(due));
|
||||
assert_eq!(
|
||||
timer.armed.borrow().as_ref().unwrap().1,
|
||||
original,
|
||||
"unchanged deadline keeps its source"
|
||||
);
|
||||
timer.update(Some(future));
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 0, "the replaced due timer must never fire");
|
||||
state.timer.as_ref().unwrap().update(Some(due));
|
||||
state.timer.as_ref().unwrap().update(None);
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 0, "cancelling the due timer removes its wakeup");
|
||||
assert!(state.timer.as_ref().unwrap().armed.borrow().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_deadline_drop_removes_a_registered_due_source() {
|
||||
let (mut event_loop, mut state) = deadline_loop();
|
||||
state
|
||||
.timer
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.update(Some(Instant::now() - Duration::from_secs(1)));
|
||||
drop(state.timer.take());
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 0, "dropping the observer removes its timer callback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_deadline_expiry_can_rearm_during_dispatch_then_returns_to_idle() {
|
||||
let (mut event_loop, mut state) = deadline_loop();
|
||||
let due = Instant::now() - Duration::from_secs(1);
|
||||
let future = Instant::now() + Duration::from_secs(3600);
|
||||
state.next = Some(future);
|
||||
state.timer.as_ref().unwrap().update(Some(due));
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.timer
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.armed
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|(deadline, _)| *deadline),
|
||||
Some(future)
|
||||
);
|
||||
state.timer.as_ref().unwrap().update(Some(due));
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 2);
|
||||
assert!(state.timer.as_ref().unwrap().armed.borrow().is_none());
|
||||
event_loop.dispatch(Duration::ZERO, &mut state).unwrap();
|
||||
assert_eq!(state.fired, 2, "an empty queue has no repeat wakeup");
|
||||
}
|
||||
|
||||
fn request(version: u32, kind: &str) -> Request {
|
||||
Request {
|
||||
version,
|
||||
|
||||
@@ -767,6 +767,9 @@ impl Ferese {
|
||||
};
|
||||
state.config_worker = Some(crate::reload::Worker::new(event_loop)?);
|
||||
let screenshot = crate::ipc::init(event_loop)?;
|
||||
state
|
||||
.screenshot
|
||||
.set_deadline_observer(move |deadline| screenshot.deadline_timer.update(deadline));
|
||||
state.screenshot_parts = Some(screenshot.parts);
|
||||
state.screenshot_worker = Some(screenshot.worker);
|
||||
state._ipc_socket = Some(screenshot._guard);
|
||||
|
||||
Reference in new issue
Block a user