1
\$\begingroup\$

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 as setup_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.)

\$\endgroup\$
4
  • \$\begingroup\$ It would be helpful to the Gentle Reader if documentation within the source code explicitly pinned down that debounce / low-pass filtering / flap suppression / hysteresis has the effect of delaying signals by 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\$
    – J_H
    Aug 26 at 21:13
  • \$\begingroup\$ @J_H documenting the delay is a good point (in fact ofc the delay is longer, as the async loop isn't particularly tight). 'flap suppression' is a good phrase. \$\endgroup\$
    – 2e0byo
    Aug 26 at 21:34
  • \$\begingroup\$ @J_H re interfaces (and testing, which I considered and decided against even for a learning project: this really is just a script) I'd be interesting to see how you'd structure it. I can't see how to pull the logic out of the implementation easily without running into hurdle with rust, but I'm not (yet) at home in it. \$\endgroup\$
    – 2e0byo
    Aug 26 at 21:37
  • \$\begingroup\$ "pull the logic out ..." Well, I was focused more on the data structures and side effects. The way I think about BGP flap suppression is, it's kind of like "cleaning up history". That is, events happened, at timestamps, that much is definite. But then it's a value judgement to say "did this ON followed by OFF event really matter?", if they were closely spaced. So for a given 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. The sleep() side effect is even more trouble \$\endgroup\$
    – J_H
    Aug 27 at 0:34

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.