use std::collections::HashMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use crate::{ChildMessage, ShepherdMessage};
pub type ActionHandler = Box<dyn Fn(Option<&str>, &str) -> String + Send + Sync + 'static>;
pub type ShutdownHandler = Box<dyn Fn() + Send + Sync + 'static>;
type ActionFn = dyn Fn(Option<&str>, &str) -> String + Send + Sync;
type ShutdownFn = dyn Fn() + Send + Sync;
#[derive(Debug)]
pub(crate) enum Outcome {
Reply(ChildMessage),
Handled,
UnhandledShutdown,
ShutdownFailed(String),
}
pub(crate) enum Resolved {
Action {
handler: Arc<ActionFn>,
name: String,
params: Option<String>,
id: u64,
},
UnknownAction {
name: String,
id: u64,
},
Shutdown(Arc<ShutdownFn>),
UnhandledShutdown,
}
#[derive(Default)]
pub(crate) struct Dispatch {
actions: HashMap<String, Arc<ActionFn>>,
shutdown: Option<Arc<ShutdownFn>>,
}
impl core::fmt::Debug for Dispatch {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let mut names: Vec<&str> = self.actions.keys().map(String::as_str).collect();
names.sort_unstable();
f.debug_struct("Dispatch")
.field("actions", &names)
.field("shutdown", &self.shutdown.is_some())
.finish()
}
}
impl Dispatch {
pub(crate) fn register_action(&mut self, name: String, handler: ActionHandler) {
self.actions.insert(name, Arc::from(handler));
}
pub(crate) fn register_shutdown(&mut self, handler: ShutdownHandler) {
self.shutdown = Some(Arc::from(handler));
}
pub(crate) fn resolve(&self, message: ShepherdMessage) -> Resolved {
match message {
ShepherdMessage::Shutdown => match &self.shutdown {
Some(handler) => Resolved::Shutdown(Arc::clone(handler)),
None => Resolved::UnhandledShutdown,
},
ShepherdMessage::Action { name, params, id } => match self.actions.get(&name) {
Some(handler) => Resolved::Action {
handler: Arc::clone(handler),
name,
params,
id,
},
None => Resolved::UnknownAction { name, id },
},
}
}
#[cfg(test)]
pub(crate) fn handle(&self, message: ShepherdMessage) -> Outcome {
run(self.resolve(message))
}
}
pub(crate) fn run(resolved: Resolved) -> Outcome {
match resolved {
Resolved::Action {
handler,
name,
params,
id,
} => {
let body = match catch_unwind(AssertUnwindSafe(|| handler(params.as_deref(), &name))) {
Ok(body) => body,
Err(payload) => format!("action handler failed: {}", panic_text(&*payload)),
};
Outcome::Reply(ChildMessage::ActionReply {
action: name,
body,
id: Some(id),
})
}
Resolved::UnknownAction { name, id } => Outcome::Reply(ChildMessage::ActionReply {
body: format!("unknown action: {name}"),
action: name,
id: Some(id),
}),
Resolved::Shutdown(handler) => match catch_unwind(AssertUnwindSafe(handler.as_ref())) {
Ok(()) => Outcome::Handled,
Err(payload) => Outcome::ShutdownFailed(panic_text(&*payload)),
},
Resolved::UnhandledShutdown => Outcome::UnhandledShutdown,
}
}
fn panic_text(payload: &(dyn core::any::Any + Send)) -> String {
if let Some(text) = payload.downcast_ref::<&str>() {
(*text).to_string()
} else if let Some(text) = payload.downcast_ref::<String>() {
text.clone()
} else {
"panicked with a non-string payload".to_string()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
fn action(name: &str, params: Option<&str>, id: u64) -> ShepherdMessage {
ShepherdMessage::Action {
name: name.to_string(),
params: params.map(str::to_string),
id,
}
}
fn reply_of(outcome: Outcome) -> (String, String, Option<u64>) {
match outcome {
Outcome::Reply(ChildMessage::ActionReply { action, body, id }) => (action, body, id),
other => panic!("expected a reply, got {other:?}"),
}
}
#[test]
fn a_registered_action_gets_its_handler_and_echoes_the_id() {
let mut dispatch = Dispatch::default();
dispatch.register_action(
"gc".to_string(),
Box::new(|params, name| format!("{name} ran with {params:?}")),
);
let (action, body, id) = reply_of(dispatch.handle(action("gc", Some("now"), 7)));
assert_eq!(action, "gc");
assert_eq!(body, "gc ran with Some(\"now\")");
assert_eq!(
id,
Some(7),
"the id must be echoed or the reply races the timeout"
);
}
#[test]
fn an_unregistered_action_still_gets_a_reply() {
let dispatch = Dispatch::default();
let (action, body, id) = reply_of(dispatch.handle(action("reload-config", None, 3)));
assert_eq!(action, "reload-config");
assert_eq!(body, "unknown action: reload-config");
assert_eq!(id, Some(3));
}
#[test]
fn a_panicking_handler_replies_with_the_panic_message() {
let mut dispatch = Dispatch::default();
dispatch.register_action("boom".to_string(), Box::new(|_, _| panic!("no such state")));
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = dispatch.handle(action("boom", None, 11));
std::panic::set_hook(previous);
let (action, body, id) = reply_of(outcome);
assert_eq!(action, "boom");
assert_eq!(body, "action handler failed: no such state");
assert_eq!(id, Some(11));
}
#[test]
fn a_shutdown_runs_its_handler_exactly_once() {
let hits = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&hits);
let mut dispatch = Dispatch::default();
dispatch.register_shutdown(Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
assert!(matches!(
dispatch.handle(ShepherdMessage::Shutdown),
Outcome::Handled
));
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[test]
fn a_shutdown_with_no_handler_is_reported_rather_than_ignored() {
let dispatch = Dispatch::default();
assert!(matches!(
dispatch.handle(ShepherdMessage::Shutdown),
Outcome::UnhandledShutdown
));
}
#[test]
fn a_panicking_shutdown_handler_is_reported_rather_than_taking_the_reader_down() {
let mut dispatch = Dispatch::default();
dispatch.register_shutdown(Box::new(|| panic!("no such state")));
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = dispatch.handle(ShepherdMessage::Shutdown);
std::panic::set_hook(previous);
match outcome {
Outcome::ShutdownFailed(message) => assert_eq!(message, "no such state"),
other => panic!("expected ShutdownFailed, got {other:?}"),
}
}
#[test]
fn debug_names_the_registered_actions_and_nothing_else() {
let mut dispatch = Dispatch::default();
dispatch.register_action("gc".to_string(), Box::new(|_, _| String::new()));
dispatch.register_action("dump".to_string(), Box::new(|_, _| String::new()));
assert_eq!(
format!("{dispatch:?}"),
"Dispatch { actions: [\"dump\", \"gc\"], shutdown: false }"
);
}
}