Clipboard Watcher Design (Rust Concurrency)
- Level 0
- Level 1
- Level 1.1
- Level 1.2
- Level 1.3
- Level 2
- Level 2.1
- Level 2.2: Simplify with tokio
- Level 2.3: Generics
In this article, I discuss how to implement a clipboard watcher (or any other event listener) in Rust.
The following topics are covered
- Creating callback functions
- Using trait to define handlers
- Using trait to handle multi-platform implementation of the watcher
- Using Arc + Mutex to share data between threads
- Rust generics
- How to stop a watcher thread using
- Channel
- Flag (
AtomicBool
) abort()
of tokio'sJoinHandle
This sample demonstrates some designs for a clipboard watcher, or any watcher/listener/monitor.
Here are some requirements:
- The watcher should be able to start and stop.
- The watcher can accept multiple callback functions, these functions will be called when the watcher detects a change.
- The watcher should be able to detect changes in the clipboard.
- The watcher shouldn't block the main thread.
First I let ChatGPT 4 write the code for me.
Level 0
The example above is the simplest design, without the ability to stop the watcher.
Level 1
thread + channel
Here is the next design by ChatGPT 4.
Now the watcher can be stopped with the help of crossbeam_channel
. if r.try_recv().is_ok()
checks if the watcher should stop.
This is actually similar to using a running
flag. I had examples discussing using a boolean running
flag in the discussion on Concurrency.
tokio
I modified the previous design to use tokio
instead of thread
+ crossbeam_channel
, the resulting code is simpler as tokio tasks has an .abort()
option.
For a simpler example like this, look at my discussion in concurrency.md.
Level 1.1
Then I tell ChatGPT 4 I want the callback function to be able to access some variable.
Level 1.2
Great, but I want the user_id
to be able to be mutable.
ChatGPT gives me.
Level 1.3
Rather than using type Callback = Box<dyn Fn(String) + Send + Sync>;
type for callback, we can use a trait and a user-custom struct.
See the example below.
A struct gives us more flexibility and control over the callback function. We can implement custom methods and add more fields to the struct.
Level 2
Here is a simplified version of how clipboard-rs
crate implements the clipboard watcher.
It's much more complicated. Let's discuss the design.
-
Channel is used to send a stop signal to the watcher.
When
stop()
is called, drop is called, and the stop signal is sent.On the other side, in the loop, the receiver checks if the signal is received every 500ms. Once signal received, the loop breaks, and
start_watch_block()
returns. -
ClipboardHandler
is designed as a schema for callback "functions". The watcher knows the handler must have aon_clipboard_change
method. -
ClipboardWatcher
declares the methods a watcher should have. The reason for this is thatclipboard-rs
crate has multiple implementations of the watcher. One for each platform (Mac, Windows, Linux). Each platform has their ownClipboardWatcherContext
that could contain some platform-specific fields (in our simplified example there is no platform-specific code, but inclipboard-rs
's original code, theclipboard
field in Mac'sClipboardWatcherContext
has aNSPasteboard
fromcocoa
, a Mac API binding crate. This field is not in Windows'ClipboardWatcherContext
). So, depending on the real life scenario, this Trait may or may not be necessary. -
TODO: explains the
unsafe impl
ofSend
trait onClipboardWatcherContext
Level 2.1
In this level I implemented a similar design to the previous one, but with a fire and forget start()
funciton.
It runs watcher in the background, without needing users to manually run thread::spawn
, and there is a watcher.stop()
function to stop the watcher thread at any time.
A start_block()
function is also implemented, which run in the main thread and blocks the main thread.
However, there is a small problem with this design:
Problem 1
I have to manually spawn a new thread. Ideally I want to encapsulate the thread spawning inside the
Watcher
struct.
If I want to run the watcher in a manually spawned thread, I can't use watcher.stop()
to stop the watcher.
There could be multiple solutions to this problem:
-
Use
Arc<Mutex<Watcher>>
to wrap the watcher, and clone theArc
before moving it to the thread. -
Use the
WatcherShutdown
design from Level 2.
Level 2.2: Simplify with tokio
The previous design is good, but implementation gets complicated as we need to use a running
flag to stop the thread. There is no way to stop a thread.
With tokio
, we can use tokio::task::JoinHandle
to stop the thread, who has an .abort()
function.
Simply store the JoinHandle
in the watcher, and call .abort()
to stop the watcher. Same as the example in concurrency.md.
join_handle
is stored as a Option. When it's None
, the watcher is not running. When it's Some
, the watcher is running.
The start function simply spawns a new task, and stores the JoinHandle
in the watcher.
In main function, we can start the watcher with watcher.start().await
, and stop it with watcher.stop()
.
Level 2.3: Generics
The generic is very complicated
Sync
and Send
are necessary to make sure handlers can be used in threads. 'static
is used to make sure the handlers live a long enough time.
There is not really a good solution for this, but I want to discus the generics used here.
Let's forget about Sync + Send + 'static
for a moment.
The error message is clear, the size of the implementations of trait ClipboardHandler
is unknown at compile time. This is because ClipboardHandler
is a trait, and trait objects have a dynamic size. handlers: Vec<Box<dyn ClipboardHandler>>
Anyways, when Box
and dyn
are used, the code will get more and more complicated.
The resulting handlers will be Box<dyn ClipboardHandler + Send + Sync + 'static>
, which is very complicated.
Thus, in previous implementation I used is impl<T: ClipboardHandler> WatcherTrait<T> for Watcher<T> {...}
.
T
has to be a struct that implements ClipboardHandler
, which means the type of T
is consistent, and size of T
is known at compile time.
For example, in the following code, T
is ClipboardHandlerImpl
, which is a struct that implements ClipboardHandler
. The size of ClipboardHandlerImpl
is known at compile time.
If you want to use the Box<dyn ClipboardHandler>
approach, here is the fully working code.
Using smart pointers Box
and dyn
keyword is a good way to handle trait objects, but it makes the code more complicated.
Here is an example, when adding handler, it has to be wrapped in a Box
.
Personally I believe level 2.2 is a better design, as it is simpler and more straightforward.
How is this guide?