Here is a tiny (cli) tool to run a script with the state of any of a set of watched files when one of them is opened or closed. It applies basic debouncing to handle bursty access. It was written to run a script (oncall.py
) whenever the webcam is accessed (hence the output semantics), without thrashing it. I thought it would be quicker to use inotify directly than to filter e.g. inotifywait
, and more fun (offsetting the speed) to use rust rather than python or c.
use clap::Parser;
use inotify::{EventMask, Inotify, WatchMask};
use std::process::Command;
use tokio::{
sync::mpsc::{channel, Receiver, Sender},
task::{self, JoinHandle},
time::{sleep, Duration},
};
use tokio_stream::StreamExt;
fn setup_watchers(paths: Vec<String>) -> Inotify {
let inotify = Inotify::init().expect("Error initialising inotify");
for path in paths {
inotify
.watches()
.add(path.clone(), WatchMask::OPEN | WatchMask::CLOSE)
.expect(&format!("Failed to add watch for {}", path));
}
inotify
}
async fn filter(state: bool, delay_ms: u64, tx: Sender<bool>) {
sleep(Duration::from_millis(delay_ms)).await;
tx.send(state)
.await
.expect("Unable to send state for processing.");
}
async fn update(mut rx: Receiver<bool>, cmd: String, on: String, off: String) {
let mut state = false;
let cmd = cmd.clone();
let on = on.clone();
let off = off.clone();
while let Some(msg) = rx.recv().await {
if msg != state {
state = msg;
let res = Command::new(&cmd)
.arg(match state {
true => &on,
false => &off,
})
.spawn();
match res {
Ok(_) => (),
Err(e) => println!(
"Failed to start process \"{cmd} {args}\": {error:?}",
cmd = cmd,
args = state,
error = e
),
}
}
}
}
/// Debounced inotify watcher
#[derive(Parser, Debug)]
#[command(author, version, about, long_about=None)]
struct Args {
/// Command to call; will receive state as first arg.
cmd: String,
/// Files to watch
paths: Vec<String>,
/// Filter delay in ms.
#[arg(short, long, default_value_t = 500)]
delay: u64,
/// String to pass when resource in use.
#[arg(long, default_value = "on")]
on: String,
/// String to pass when resource not in use.
#[arg(long, default_value = "off")]
off: String,
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let args = Args::parse();
let inotify = setup_watchers(args.paths);
let buffer = [0; 1024];
let stream = inotify
.into_event_stream(buffer)
.expect("Failed to start stream.")
.filter(|msg| match msg {
Ok(_) => true,
_ => false,
})
.map(|msg| match msg.unwrap().mask {
EventMask::OPEN => true,
EventMask::CLOSE_NOWRITE | EventMask::CLOSE_WRITE => false,
other => panic!("Unknown event: {:?}", other),
});
tokio::pin!(stream);
let (tx, rx) = channel::<bool>(32);
tokio::spawn(update(rx, args.cmd, args.on, args.off));
let mut fut: Option<JoinHandle<()>> = None;
while let Some(state) = stream.next().await {
match fut {
None => (),
Some(fut) => fut.abort(),
};
fut = Some(task::spawn(filter(state, args.delay, tx.clone())));
}
}
Cargo requires the following dependencies:
[dependencies]
clap = { version = "4.4.0", features = ["derive"] }
inotify = "0.10.2"
tokio = {version = "1.32.0", features = ["rt", "macros", "sync"]}
tokio-stream = "0.1.14"
I admire rust greatly and mean to learn it properly some day. Several things felt off when writing this:
- Naturally I'd write
setup_watchers
assetup_event_stream
(or the like): the stream is all I care about, but passing it out ended up with it wrapped in a pinned box which felt even more ugly. - Surely there's some neater way of catching the exception in
update
than assigning and then a match? - I wanted to do message passing and async, but I'm not massively happy with the filter implementation.
- I'd like to use a custom enum for the state to make it easier to reason about, but found it surprisingly hard.
- I don't love all the
.clone()
s: they feel like I'm doing something at the wrong boundary.
Criticism of these decisions, and general pointers for idiomatic rust, would be very appreciated. (Criticism of the whole thing is on topic, but I wouldn't write a general cli tool with such restrictive semantics. It does one thing well locally.)
delay
ms, and suppressing any signal variations of shorter duration. Also, I am sad that the interfaces used make it mostly impossible to write small automated unit tests which replay historically observed signal timings. \$\endgroup\$delay
parameter we can clean up or suppress certain transitions. My usual unit testing bugaboo is relying on a global, e.g. a time() clock -- pass in a time param instead. Thesleep()
side effect is even more trouble \$\endgroup\$