use std::fs;
use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use log::warn;
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use rustix::process::{PidfdFlags, pidfd_open};
use tokio::io::unix::AsyncFd;
use tokio::process::Command as TokioCommand;
use tokio::time;
use crate::command::CommandReceiver;
use crate::producer::{MsgSender, Producer, ProducerFuture, ProducerResult};
use crate::widget::{Command, Hypridle, Msg};
const PROC_ROOT: &str = "/proc";
const HYPRIDLE_EXECUTABLE: &str = "hypridle";
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(10);
const STOP_TIMEOUT: Duration = Duration::from_secs(1);
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HypridleAction {
None,
Start,
Stop,
}
fn required_action(active: bool, desired: bool) -> HypridleAction {
match (active, desired) {
(false, true) => HypridleAction::Start,
(true, false) => HypridleAction::Stop,
_ => HypridleAction::None,
}
}
fn discover_hypridle_processes(root: &Path) -> io::Result<Vec<i32>> {
let current_uid = fs::metadata(root.join("self"))
.or_else(|_| fs::metadata(root))?
.uid();
let mut pids = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<i32>().ok())
else {
continue;
};
if !is_hypridle(root, pid) {
continue;
}
if entry
.metadata()
.is_ok_and(|metadata| metadata.uid() == current_uid)
{
pids.push(pid);
}
}
pids.sort_unstable();
Ok(pids)
}
fn active(root: &Path) -> io::Result<bool> {
Ok(!discover_hypridle_processes(root)?.is_empty())
}
#[derive(Debug, Default)]
struct Tracker {
pid: Option<i32>,
exited: Option<i32>,
}
impl Tracker {
fn active(&mut self, root: &Path) -> io::Result<bool> {
if let Some(pid) = self.pid
&& is_hypridle(root, pid)
{
return Ok(true);
}
let pids = discover_hypridle_processes(root)?;
self.exited = self.exited.filter(|exited| pids.contains(exited));
self.pid = pids.into_iter().find(|pid| Some(*pid) != self.exited);
Ok(self.pid.is_some())
}
async fn changed(&mut self, interval: Duration) {
match self.pid.map(ProcessExit::watch) {
Some(Ok(exit)) => {
exit.wait().await;
self.exited = self.pid.take();
}
Some(Err(error)) => {
if error.kind() != io::ErrorKind::NotFound {
time::sleep(interval).await;
}
}
None => time::sleep(interval).await,
}
}
}
fn is_hypridle(root: &Path, pid: i32) -> bool {
fs::read_to_string(root.join(pid.to_string()).join("comm"))
.is_ok_and(|comm| comm.trim_end() == HYPRIDLE_EXECUTABLE)
}
struct ProcessExit(AsyncFd<OwnedFd>);
impl ProcessExit {
fn watch(pid: i32) -> io::Result<Self> {
let pid = rustix::process::Pid::from_raw(pid)
.ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
let fd = pidfd_open(pid, PidfdFlags::NONBLOCK).map_err(|errno| match errno {
rustix::io::Errno::SRCH => io::Error::from(io::ErrorKind::NotFound),
errno => io::Error::from(errno),
})?;
AsyncFd::new(fd).map(Self)
}
async fn wait(self) {
let _ = self.0.readable().await;
}
}
pub struct HypridleProducer {
root: PathBuf,
interval: Duration,
}
impl HypridleProducer {
pub fn new() -> Self {
Self::with_interval(DEFAULT_INTERVAL)
}
pub fn with_interval(interval: Duration) -> Self {
Self {
root: PathBuf::from(PROC_ROOT),
interval,
}
}
}
impl Default for HypridleProducer {
fn default() -> Self {
Self::new()
}
}
impl Producer for HypridleProducer {
fn name(&self) -> String {
"hypridle".to_string()
}
fn run(self: Box<Self>, tx: MsgSender) -> ProducerFuture {
Box::pin(run_producer(self.root, self.interval, tx))
}
}
async fn run_producer(root: PathBuf, interval: Duration, tx: MsgSender) -> ProducerResult {
let mut previous = None;
let mut tracker = Tracker::default();
loop {
let next = match tracker.active(&root) {
Ok(active) => active,
Err(error) => {
warn!("hypridle: reading process state failed: {error}");
time::sleep(interval).await;
continue;
}
};
if previous != Some(next) {
previous = Some(next);
if tx.send(Msg::Hypridle(Hypridle::new(next))).is_err() {
return Ok(());
}
}
tracker.changed(interval).await;
}
}
fn signal_stop(pids: &[i32]) -> io::Result<()> {
for &pid in pids {
if let Err(error) = kill(Pid::from_raw(pid), Signal::SIGTERM)
&& error != Errno::ESRCH
{
return Err(io::Error::from_raw_os_error(error as i32));
}
}
Ok(())
}
async fn wait_until_stopped(root: &Path) -> io::Result<bool> {
let deadline = time::Instant::now() + STOP_TIMEOUT;
loop {
if !active(root)? {
return Ok(false);
}
if time::Instant::now() >= deadline {
return Ok(true);
}
time::sleep(STOP_POLL_INTERVAL).await;
}
}
async fn set_state(root: &Path, executable: &Path, desired: bool) -> io::Result<bool> {
let pids = discover_hypridle_processes(root)?;
match required_action(!pids.is_empty(), desired) {
HypridleAction::None => Ok(desired),
HypridleAction::Start => {
TokioCommand::new(executable)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(true)
}
HypridleAction::Stop => {
signal_stop(&pids)?;
wait_until_stopped(root).await
}
}
}
pub async fn run_commands(mut commands: CommandReceiver, updates: MsgSender) -> ProducerResult {
while let Some(command) = commands.recv().await {
let Command::SetHypridle(desired) = command else {
continue;
};
let state = match set_state(
Path::new(PROC_ROOT),
Path::new(HYPRIDLE_EXECUTABLE),
desired,
)
.await
{
Ok(state) => state,
Err(error) => {
warn!("hypridle: setting active={desired} failed: {error}");
match active(Path::new(PROC_ROOT)) {
Ok(state) => state,
Err(refresh_error) => {
warn!("hypridle: refreshing after command failed: {refresh_error}");
continue;
}
}
}
};
if updates.send(Msg::Hypridle(Hypridle::new(state))).is_err() {
return Ok(());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::{Duration, Instant};
use super::{
HypridleAction, ProcessExit, Tracker, discover_hypridle_processes, required_action,
};
fn process(root: &std::path::Path, pid: i32, name: &str) {
let path = root.join(pid.to_string());
fs::create_dir(&path).unwrap();
fs::write(path.join("comm"), format!("{name}\n")).unwrap();
}
#[test]
fn discovery_matches_exact_same_user_process_names() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
process(root.path(), 42, "hypridle");
process(root.path(), 43, "hypridle-helper");
process(root.path(), 44, "Hypridle");
let pids = discover_hypridle_processes(root.path()).unwrap();
assert_eq!(pids, vec![42]);
}
#[test]
fn discovery_ignores_non_process_entries_and_missing_comm_files() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
fs::create_dir(root.path().join("51")).unwrap();
process(root.path(), 52, "hypridle");
let pids = discover_hypridle_processes(root.path()).unwrap();
assert_eq!(pids, vec![52]);
}
#[test]
fn desired_state_is_idempotent() {
assert_eq!(required_action(false, false), HypridleAction::None);
assert_eq!(required_action(true, true), HypridleAction::None);
assert_eq!(required_action(false, true), HypridleAction::Start);
assert_eq!(required_action(true, false), HypridleAction::Stop);
}
#[test]
fn a_tracked_process_is_confirmed_without_rescanning() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
process(root.path(), 42, "hypridle");
let mut tracker = Tracker::default();
assert!(tracker.active(root.path()).unwrap());
assert_eq!(tracker.pid, Some(42));
fs::remove_dir(root.path().join("self")).unwrap();
process(root.path(), 7, "hypridle");
assert!(tracker.active(root.path()).unwrap());
assert_eq!(tracker.pid, Some(42));
}
#[test]
fn a_vanished_or_recycled_pid_falls_back_to_a_scan() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
process(root.path(), 42, "hypridle");
let mut tracker = Tracker::default();
assert!(tracker.active(root.path()).unwrap());
fs::write(root.path().join("42/comm"), "bash\n").unwrap();
process(root.path(), 99, "hypridle");
assert!(tracker.active(root.path()).unwrap());
assert_eq!(tracker.pid, Some(99));
fs::remove_dir_all(root.path().join("99")).unwrap();
assert!(!tracker.active(root.path()).unwrap());
assert_eq!(tracker.pid, None);
}
#[test]
fn an_exited_process_still_listed_as_a_zombie_is_not_running() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("self")).unwrap();
process(root.path(), 42, "hypridle");
let mut tracker = Tracker {
pid: None,
exited: Some(42),
};
assert!(!tracker.active(root.path()).unwrap());
fs::remove_dir_all(root.path().join("42")).unwrap();
assert!(!tracker.active(root.path()).unwrap());
process(root.path(), 42, "hypridle");
assert!(tracker.active(root.path()).unwrap());
}
#[test]
fn a_process_exit_ends_the_wait_without_polling() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let mut child = std::process::Command::new("sleep")
.arg("60")
.spawn()
.unwrap();
let pid = child.id() as i32;
runtime.block_on(async {
let exit = ProcessExit::watch(pid).unwrap();
let waiting = tokio::spawn(exit.wait());
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!waiting.is_finished(), "still running");
let killed = Instant::now();
child.kill().unwrap();
tokio::time::timeout(Duration::from_secs(5), waiting)
.await
.expect("exit observed")
.unwrap();
assert!(killed.elapsed() < Duration::from_secs(1));
});
child.wait().unwrap();
}
#[test]
fn watching_a_process_that_is_already_gone_reports_not_found() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = child.id() as i32;
child.wait().unwrap();
let error = runtime
.block_on(async { ProcessExit::watch(pid).map(|_| ()) })
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
}
}