To build an application that resides on a Raspberry Pi (but that we can test on our workstations) that hosts our indicator modules, we’ll need to employ a few techniques that we haven’t covered in this book yet. The requirements for our Generic Indicator Module System are as follows:
Load a WebAssembly module at launch and immediately run, controlling LEDs
Enforce a fixed frame rate for animated modules
Trap the SIGINT and SIGTERM signals, gracefully turning off active LEDs before shutdown
Hot Reloading—if a new module is copied into a monitored location, pause, then load the new module and continue
The application is designed to start and continue running forever, constantly getting new sensor inputs (though we’re only faking one sensor) and feeding that data to the indicator module. To make all of this work, you’ll get into some new Rust patterns like using multi-threaded code, channels, and conditional compilation.
In the previous section, you created an empty application called pihost. At the moment there’s nothing special that you need to do to make this application suitable for a Raspberry Pi. We do need to pick some dependencies—crates that will help us monitor the file system, read and execute WebAssembly modules, respond to OS signals, and operate the Blinkt hardware module. This is a Cargo.toml that pulls in those dependencies:
| [package] |
| name = "pihost" |
| version = "0.1.0" |
| authors = ["your Email <[email protected]>"] |
| |
| [dependencies] |
| notify = "4.0.0" |
| wasmi = "0.4.1" |
| ctrlc = { version = "3.0", features = ["termination"] } |
| |
» | [target.'cfg(any(target_arch = "arm", target_arch = "armv7"))'.dependencies] |
» | blinkt = "0.4.0" |
This is the first time you’ve seen conditional compilation in action. The highlighted lines will only include the blinkt dependency when you’re compiling for the ARM architecture.
There are a number of mission-critical tasks in the application and, as you’ll see, coordinating them can be really tricky. Thankfully Rust makes it pretty easy. The first task is monitoring the file system and then letting the main module runner know that it needs to reload a module. The following two functions make that possible:
| #[cfg(any(target_arch = "armv7", target_arch = "arm"))] |
| extern crate blinkt; |
| |
| extern crate ctrlc; |
| extern crate notify; |
| extern crate wasmi; |
| |
| use notify::{DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher}; |
| use std::path::Path; |
| use std::sync::mpsc::{channel, RecvTimeoutError, Sender}; |
| use std::thread; |
| use std::time::Duration; |
| use wasm::Runtime; |
| use wasmi::RuntimeValue; |
| |
| const MODULE_FILE: &'static str = "/home/kevin/indicators/indicator.wasm"; |
| const MODULE_DIR: &'static str = "/home/kevin/indicators"; |
| |
| enum RunnerCommand { |
| Reload, |
| Stop, |
| } |
| |
| fn watch(tx_wasm: Sender<RunnerCommand>) -> notify::Result<()> { |
① | let (tx, rx) = channel(); |
| |
| let mut watcher: RecommendedWatcher = |
| Watcher::new(tx, Duration::from_secs(1))?; |
| watcher.watch(MODULE_DIR, RecursiveMode::NonRecursive)?; |
| |
| loop { |
② | match rx.recv() { |
| Ok(event) => handle_event(event, &tx_wasm), |
| Err(e) => println!("watch error: {:?}", e), |
| } |
| } |
| } |
| |
| fn handle_event(event: DebouncedEvent, tx_wasm: &Sender<RunnerCommand>) { |
| match event { |
| DebouncedEvent::NoticeWrite(path) => { |
| let path = Path::new(&path); |
| let filename = path.file_name().unwrap(); |
| if filename == "indicator.wasm" { |
③ | tx_wasm.send(RunnerCommand::Reload).unwrap(); |
| } else { |
| println!("write (unexpected file): {:?}", path); |
| } |
| } |
| _ => {} |
| } |
| } |
Creates a multi-producer, single-consumer communication channel
Block the receive channel until a message arrives
Send a message on the channel, indicating that we should reload the WebAssembly module
In Rust, multi-producer-single-consumer (mpsc) channels are used as a safe way to communicate between threads. In the case of the watch function, the main thread sits in a loop, awaiting file system notifications from the monitored directory. When we see the NoticeWrite event, it’s time to tell another thread to reload the module from disk.
In the last section, you saw code that sends a message on a channel to tell another thread to reload a WebAssembly module. In this section, you’ll create that main thread. This thread has a number of jobs. It obviously needs to listen for the RunnerCommand::Reload message, but it also needs to handle the RunnerCommand::Stop message (which it will get from us monitoring OS signals). It also needs to invoke methods on the module itself, setting the fake sensor input value and calling apply to take care of animations.
This is where it would be so much easier to throw up our hands and walk away. Juggling file system monitoring, two different threads, signal trapping, and ensuring a consistent frame rate in the WebAssembly module sounds as complicated as dealing with the three-body problem.[34]
Luckily, there’s a solution that doesn’t require creating yet another coordination thread. Instead, we can use the timeout feature of channel receives. If we wait for the inverse of the frame rate in milliseconds for a message to arrive, and no messages comes, then we can invoke the apply function on the WebAssembly module. For example, if we want to enforce 20fps, then we would set our receive timeout delay to 50 milliseconds. For a frame rate of 10fps, we’d set the delay to 100 milliseconds, which actually produces a pretty good effect on the Blinkt hardware.
Let’s take a look at the code for the main function (anything from the wasm module is code that we’ll write shortly):
| fn main() { |
| let (tx_wasm, rx_wasm) = channel(); |
| let _indicator_runner = thread::spawn(move || { |
| let mut runtime = Runtime::new(); |
| let mut module = wasm::get_module_instance(MODULE_FILE); |
| println!("Starting wasm runner thread..."); |
| loop { |
① | match rx_wasm.recv_timeout(Duration::from_millis(100)) { |
| Ok(RunnerCommand::Reload) => { |
| println!("Received a reload signal, sleeping for 2s"); |
| thread::sleep(Duration::from_secs(2)); |
| module = wasm::get_module_instance(MODULE_FILE); |
| } |
| Ok(RunnerCommand::Stop) => { |
| runtime.shutdown(); |
| break; |
| } |
| Err(RecvTimeoutError::Timeout) => { |
| runtime.reduce_battery(); |
| runtime.advance_frame(); |
| module |
| .invoke_export( |
| "sensor_update", |
| &[ |
| RuntimeValue::from(wasm::SENSOR_BATTERY), |
| RuntimeValue::F64( |
| runtime.remaining_battery.into()), |
| ][..], |
| &mut runtime, |
| ).unwrap(); |
| |
| module |
| .invoke_export( |
| "apply", |
| &[RuntimeValue::from(runtime.frame)][..], |
| &mut runtime, |
| ).unwrap(); |
| } |
| Err(_) => break, |
| } |
| } |
| }); |
| |
② | let tx_wasm_sig = tx_wasm.clone(); |
| |
③ | ctrlc::set_handler(move || { |
| tx_wasm_sig.send(RunnerCommand::Stop).unwrap(); |
| }).expect("Error setting Ctrl-C handler"); |
| |
④ | if let Err(e) = watch(tx_wasm) { |
| println!("error: {:?}", e) |
| } |
| } |
| |
| mod wasm; |
Enforce the frame rate with a 100ms timeout value on receive
Send channels can be cloned, hence their presence in the multi-producer module
Use the ctrlc crate to trap SIGTERM and SIGINT, sending a Stop command in response
The watch function blocks the main thread with an infinite loop
Let’s create the wasm.rs module that was referenced by the previous set of code:
| use std::fmt; |
| use std::fs::File; |
| use wasmi::{ |
| Error as InterpreterError, Externals, FuncInstance, FuncRef, |
| HostError, ImportsBuilder, Module, ModuleImportResolver, ModuleInstance, |
| ModuleRef, RuntimeArgs, RuntimeValue, Signature, Trap, ValueType, |
| }; |
| |
① | #[cfg(any(target_arch = "armv7", target_arch = "arm"))] |
| use blinkt::Blinkt; |
| |
| fn load_module(path: &str) -> Module { |
| use std::io::prelude::*; |
| let mut file = File::open(path).unwrap(); |
| let mut wasm_buf = Vec::new(); |
| file.read_to_end(&mut wasm_buf).unwrap(); |
| Module::from_buffer(&wasm_buf).unwrap() |
| } |
| |
| pub fn get_module_instance(path: &str) -> ModuleRef { |
| let module = load_module(path); |
| let mut imports = ImportsBuilder::new(); |
| imports.push_resolver("env", &RuntimeModuleImportResolver); |
| |
| ModuleInstance::new(&module, &imports) |
| .expect("Failed to instantiate module") |
| .assert_no_start() |
| } |
| |
| pub const SENSOR_BATTERY: i32 = 20; |
| |
| |
| #[derive(Debug)] |
| pub enum Error { |
| Interpreter(InterpreterError), |
| } |
| |
| impl fmt::Display for Error { |
| fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { |
| write!(f, "{:?}", self) |
| } |
| } |
| |
| impl From<InterpreterError> for Error { |
| fn from(e: InterpreterError) -> Self { |
| Error::Interpreter(e) |
| } |
| } |
| |
| impl HostError for Error {} |
| |
| pub struct Runtime { |
| #[cfg(any(target_arch = "armv7", target_arch = "arm"))] |
② | blinkt: Blinkt, |
| pub frame: i32, |
| pub remaining_battery: f64, |
| } |
| |
| impl Runtime { |
| #[cfg(any(target_arch = "armv7", target_arch = "arm"))] |
| pub fn new() -> Runtime { |
| println!("Instiantiating WASM runtime (ARM)"); |
| Runtime { |
| blinkt: Blinkt::new().unwrap(), |
| frame: 0, |
| remaining_battery: 100.0, |
| } |
| } |
| |
| #[cfg(not(any(target_arch = "armv7", target_arch = "arm")))] |
| pub fn new() -> Runtime { |
| println!("Instantiating WASM runtime (non-ARM)"); |
| Runtime { |
| frame: 0, |
| remaining_battery: 100.0, |
| } |
| } |
| } |
| |
| impl Externals for Runtime { |
| fn invoke_index( |
| &mut self, |
| index: usize, |
| args: RuntimeArgs, |
| ) -> Result<Option<RuntimeValue>, Trap> { |
| |
③ | match index { |
| 0 => { |
| let idx: i32 = args.nth(0); |
| let red: i32 = args.nth(1); |
| let green: i32 = args.nth(2); |
| let blue: i32 = args.nth(3); |
| self.set_led(idx, red, green, blue); |
| Ok(None) |
| } |
| _ => panic!("Unknown function index!"), |
| } |
| } |
| } |
| |
| impl Runtime { |
| #[cfg(not(any(target_arch = "armv7", target_arch = "arm")))] |
| fn set_led(&self, idx: i32, red: i32, green: i32, blue: i32) { |
| println!("[LED {}]: {}, {}, {}", idx, red, green, blue); |
| } |
| |
| #[cfg(any(target_arch = "armv7", target_arch = "arm"))] |
| fn set_led(&mut self, idx: i32, red: i32, green: i32, blue: i32) { |
| self.blinkt |
| .set_pixel(idx as usize, red as u8, green as u8, blue as u8); |
| self.blinkt.show().unwrap(); |
| } |
| |
| #[cfg(not(any(target_arch = "armv7", target_arch = "arm")))] |
| pub fn shutdown(&mut self) { |
| println!("WASM runtime shut down."); |
| self.halt(); |
| } |
| |
| #[cfg(any(target_arch = "armv7", target_arch = "arm"))] |
| pub fn shutdown(&mut self) { |
| println!("WASM runtime shut down."); |
| self.blinkt.clear(); |
| self.blinkt.cleanup().unwrap(); |
| self.halt(); |
| } |
| |
| fn halt(&self) { |
| ::std::process::exit(0); |
| } |
| |
| pub fn reduce_battery(&mut self) { |
| self.remaining_battery -= 1.0; |
| if self.remaining_battery < 0.0 { |
| self.remaining_battery = 100.0; |
| } |
| } |
| |
| pub fn advance_frame(&mut self) { |
| self.frame += 1; |
| if self.frame > 1_000_000_000 { |
| self.frame = 0; |
| } |
| } |
| } |
| |
| struct RuntimeModuleImportResolver; |
| |
| impl<'a> ModuleImportResolver for RuntimeModuleImportResolver { |
| fn resolve_func( |
| &self, |
| field_name: &str, |
| _signature: &Signature, |
| ) -> Result<FuncRef, InterpreterError> { |
| println!("Resolving {}", field_name); |
| let func_ref = match field_name { |
④ | "set_led" => FuncInstance::alloc_host( |
| Signature::new( |
| &[ |
| ValueType::I32, |
| ValueType::I32, |
| ValueType::I32, |
| ValueType::I32, |
| ][..], |
| None, |
| ), |
| 0, |
| ), |
| _ => { |
| return Err(InterpreterError::Function(format!( |
| "host module doesn't export function with name {}", |
| field_name |
| ))) |
| } |
| }; |
| Ok(func_ref) |
| } |
| } |
Conditionally add Blinkt to the module’s scope
Conditionally add a blinkt field to the Runtime struct
The apply function will have an index of 0.
The set_led function is the only one exported by the host/imported by the module
Most of this should look pretty familiar to you as a lot of it is just the boilerplate required to load the WebAssembly module, resolve its imports, and allow function calls.
You may have noticed one interesting thing: because of the conditional compilation, there are actually multiple functions with the same name. If we are building for ARM, then the Runtime struct gets an extra field called blinkt and the set_led function uses that field to control real hardware, whereas the “regular” version of that struct and function just emit debug text to the console.
To run the application on your workstation, just build the binary and execute it or type cargo run. This will launch the application and it will load the module at /home/kevin/indicators/indicator.wasm (feel free to change that location to suit your needs).
Running on a Mac, Windows, or Linux, you’ll see some console output spam as the module tells the host what LEDs to light up 10 times per second (the following is output from the animated pulse indicator):
| Instantiating Wasm runtime (non-ARM) |
| Resolving set_led |
| Starting wasm runner thread... |
| [LED 0]: 0, 0, 0 |
| [LED 1]: 0, 0, 0 |
| [LED 2]: 0, 0, 0 |
| [LED 3]: 0, 0, 0 |
| [LED 4]: 0, 0, 0 |
| [LED 5]: 0, 0, 0 |
| [LED 6]: 0, 0, 0 |
| [LED 7]: 0, 0, 0 |
| [LED 1]: 255, 0, 0 |
| [LED 0]: 0, 0, 0 |
| [LED 1]: 0, 0, 0 |
| [LED 2]: 0, 0, 0 |
| [LED 3]: 0, 0, 0 |
| [LED 4]: 0, 0, 0 |
| [LED 5]: 0, 0, 0 |
| [LED 6]: 0, 0, 0 |
| ^CWasm runtime shut down. |
Here I’ve tested the signal handling capabilities and the application shut down nicely in response to a Control-C. Next, we can try this out on real hardware.
3.147.84.169