use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub(crate) const HOOK_POOL_SIZE: usize = 4;
pub(crate) const HOOK_QUEUE_CAPACITY: usize = 64;
pub(crate) const HOOK_DEFAULT_TIMEOUT_MS: u32 = 5000;
pub(crate) const HOOK_MAX_TIMEOUT_MS: u32 = 30_000;
pub(crate) const HOOK_KILL_GRACE: Duration = Duration::from_millis(500);
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookEvent {
BeforePaneSpawn,
AfterPaneSpawn,
PaneDied,
PaneExited,
ClientAttached,
ClientDetached,
LayoutChanged,
TabCreated,
TabClosed,
SessionRenamed,
}
impl HookEvent {
pub fn name(self) -> &'static str {
match self {
HookEvent::BeforePaneSpawn => "before-pane-spawn",
HookEvent::AfterPaneSpawn => "after-pane-spawn",
HookEvent::PaneDied => "pane-died",
HookEvent::PaneExited => "pane-exited",
HookEvent::ClientAttached => "client-attached",
HookEvent::ClientDetached => "client-detached",
HookEvent::LayoutChanged => "layout-changed",
HookEvent::TabCreated => "tab-created",
HookEvent::TabClosed => "tab-closed",
HookEvent::SessionRenamed => "session-renamed",
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct HookDef {
pub event: HookEvent,
pub command: HookCommand,
#[serde(default)]
pub shell: bool,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u32,
}
fn default_timeout_ms() -> u32 {
HOOK_DEFAULT_TIMEOUT_MS
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum HookCommand {
String(String),
Argv(Vec<String>),
}
pub fn validate(def: &HookDef, index: usize) -> Result<(), String> {
if def.timeout_ms > HOOK_MAX_TIMEOUT_MS {
return Err(format!(
"hooks[{index}].timeout_ms must be <= {HOOK_MAX_TIMEOUT_MS}",
));
}
match &def.command {
HookCommand::String(s) => {
if s.is_empty() {
return Err(format!("hooks[{index}].command must be non-empty"));
}
if !def.shell && contains_shell_metachar(s) {
return Err(format!(
"hooks[{index}].command has shell metachars but shell=false (use shell=true or pass an argv array)",
));
}
}
HookCommand::Argv(v) => {
if v.is_empty() || v[0].is_empty() {
return Err(format!(
"hooks[{index}].command argv must have a non-empty program",
));
}
if def.shell {
return Err(format!(
"hooks[{index}].command is an argv but shell=true (set shell=false or pass a string)",
));
}
}
}
Ok(())
}
fn contains_shell_metachar(s: &str) -> bool {
s.chars()
.any(|c| matches!(c, '|' | '&' | ';' | '<' | '>' | '`' | '$' | '(' | ')'))
}
struct HookJob {
def: Arc<HookDef>,
vars: HashMap<&'static str, String>,
}
pub struct HookManager {
defs: Arc<Vec<Arc<HookDef>>>,
tx: Option<mpsc::SyncSender<HookJob>>,
workers: Vec<JoinHandle<()>>,
}
impl HookManager {
pub fn spawn(defs: Vec<HookDef>) -> Self {
let arc_defs: Vec<Arc<HookDef>> = defs.into_iter().map(Arc::new).collect();
let (tx, rx) = mpsc::sync_channel::<HookJob>(HOOK_QUEUE_CAPACITY);
let (work_tx, work_rx) = crossbeam_channel::bounded::<HookJob>(HOOK_QUEUE_CAPACITY);
let mut workers = Vec::with_capacity(HOOK_POOL_SIZE);
for worker_id in 0..HOOK_POOL_SIZE {
let rx = work_rx.clone();
let handle = std::thread::Builder::new()
.name(format!("ezpn-hooks-{worker_id}"))
.spawn(move || run_worker(rx))
.expect("spawn ezpn-hooks worker");
workers.push(handle);
}
std::thread::Builder::new()
.name("ezpn-hooks-forward".to_string())
.spawn(move || {
while let Ok(job) = rx.recv() {
if work_tx.send(job).is_err() {
break;
}
}
})
.expect("spawn hooks forwarder");
Self {
defs: Arc::new(arc_defs),
tx: Some(tx),
workers,
}
}
#[allow(dead_code)]
pub fn replace_defs(&mut self, new_defs: Vec<HookDef>) {
let arc_defs: Vec<Arc<HookDef>> = new_defs.into_iter().map(Arc::new).collect();
self.defs = Arc::new(arc_defs);
}
#[allow(dead_code)]
pub fn def_count(&self) -> usize {
self.defs.len()
}
pub fn dispatch(&self, event: HookEvent, vars: HashMap<&'static str, String>) {
let Some(tx) = self.tx.as_ref() else { return };
for def in self.defs.iter() {
if def.event != event {
continue;
}
let job = HookJob {
def: Arc::clone(def),
vars: vars.clone(),
};
if tx.try_send(job).is_err() {
eprintln!(
"ezpn: hooks queue full, dropping {} invocation",
event.name()
);
}
}
}
}
impl Drop for HookManager {
fn drop(&mut self) {
drop(self.tx.take());
for h in self.workers.drain(..) {
let _ = h.join();
}
}
}
fn run_worker(rx: crossbeam_channel::Receiver<HookJob>) {
while let Ok(job) = rx.recv() {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_hook(&job);
}));
if let Err(_payload) = result {
eprintln!("ezpn: hook worker recovered from panic");
}
}
}
fn run_hook(job: &HookJob) {
let mut cmd = match build_command(&job.def, &job.vars) {
Ok(c) => c,
Err(e) => {
eprintln!("ezpn: hook {} skipped: {e}", job.def.event.name());
return;
}
};
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
eprintln!("ezpn: hook {} spawn failed: {e}", job.def.event.name());
return;
}
};
let timeout = Duration::from_millis(job.def.timeout_ms.max(1) as u64);
use wait_timeout::ChildExt;
match child.wait_timeout(timeout) {
Ok(Some(status)) if status.success() => {
}
Ok(Some(status)) => {
eprintln!(
"ezpn: hook {} exited {}",
job.def.event.name(),
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "<signal>".to_string())
);
}
Ok(None) => {
#[cfg(unix)]
kill_pgrp(child.id() as libc::pid_t, libc::SIGTERM);
std::thread::sleep(HOOK_KILL_GRACE);
if matches!(child.try_wait(), Ok(None)) {
#[cfg(unix)]
kill_pgrp(child.id() as libc::pid_t, libc::SIGKILL);
let _ = child.wait();
}
eprintln!(
"ezpn: hook {} timed out after {} ms",
job.def.event.name(),
job.def.timeout_ms
);
}
Err(e) => {
eprintln!("ezpn: hook {} wait failed: {e}", job.def.event.name());
let _ = child.kill();
}
}
}
#[cfg(unix)]
fn kill_pgrp(pid: libc::pid_t, sig: libc::c_int) {
unsafe {
libc::kill(-pid, sig);
}
}
fn build_command(def: &HookDef, vars: &HashMap<&'static str, String>) -> Result<Command, String> {
match &def.command {
HookCommand::String(s) => {
let expanded = expand_vars(s, vars);
if def.shell {
let mut c = Command::new("/bin/sh");
c.arg("-c").arg(expanded);
Ok(c)
} else {
if expanded.contains(char::is_whitespace) {
return Err(format!(
"string command must be a single word when shell=false (got '{expanded}')",
));
}
Ok(Command::new(expanded))
}
}
HookCommand::Argv(argv) => {
if argv.is_empty() {
return Err("argv command must have at least one element".to_string());
}
let expanded: Vec<String> = argv.iter().map(|a| expand_vars(a, vars)).collect();
let mut c = Command::new(&expanded[0]);
for arg in &expanded[1..] {
c.arg(arg);
}
Ok(c)
}
}
}
pub fn expand_vars(template: &str, vars: &HashMap<&'static str, String>) -> String {
let mut out = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c != '{' {
out.push(c);
continue;
}
let mut name = String::new();
let mut closed = false;
for nc in chars.by_ref() {
if nc == '}' {
closed = true;
break;
}
name.push(nc);
}
if !closed || name.is_empty() {
out.push('{');
out.push_str(&name);
if closed {
out.push('}');
}
continue;
}
match vars.get(name.as_str()) {
Some(v) => out.push_str(v),
None => {
out.push('{');
out.push_str(&name);
out.push('}');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn vars_of(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
pairs.iter().map(|(k, v)| (*k, (*v).to_string())).collect()
}
#[test]
fn expand_substitutes_known_vars() {
let out = expand_vars(
"pane {pane_id} died with exit {exit_code}",
&vars_of(&[("pane_id", "3"), ("exit_code", "42")]),
);
assert_eq!(out, "pane 3 died with exit 42");
}
#[test]
fn expand_passes_through_unknown_keys() {
let out = expand_vars("{pane_id} and {nope}", &vars_of(&[("pane_id", "7")]));
assert_eq!(out, "7 and {nope}");
}
#[test]
fn expand_handles_empty_braces_and_unclosed() {
let out = expand_vars("a{}b{unclosed", &vars_of(&[]));
assert_eq!(out, "a{}b{unclosed");
}
#[test]
fn validate_rejects_excessive_timeout() {
let def = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::String("true".to_string()),
shell: false,
timeout_ms: 60_000,
};
let e = validate(&def, 0).unwrap_err();
assert!(e.contains("must be <="), "got: {e}");
}
#[test]
fn validate_rejects_shell_metachar_without_shell_flag() {
let def = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::String("echo hi | tee out".to_string()),
shell: false,
timeout_ms: HOOK_DEFAULT_TIMEOUT_MS,
};
let e = validate(&def, 2).unwrap_err();
assert!(e.contains("shell metachars"), "got: {e}");
}
#[test]
fn validate_rejects_argv_with_shell_true() {
let def = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::Argv(vec!["echo".into(), "hi".into()]),
shell: true,
timeout_ms: HOOK_DEFAULT_TIMEOUT_MS,
};
let e = validate(&def, 0).unwrap_err();
assert!(e.contains("argv but shell=true"), "got: {e}");
}
#[test]
fn validate_rejects_empty_argv() {
let def = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::Argv(vec![]),
shell: false,
timeout_ms: HOOK_DEFAULT_TIMEOUT_MS,
};
let e = validate(&def, 0).unwrap_err();
assert!(e.contains("non-empty program"), "got: {e}");
}
#[test]
fn validate_accepts_well_formed_string() {
let def = HookDef {
event: HookEvent::ClientAttached,
command: HookCommand::String("notify-send".to_string()),
shell: false,
timeout_ms: HOOK_DEFAULT_TIMEOUT_MS,
};
validate(&def, 0).unwrap();
}
#[test]
fn validate_accepts_well_formed_argv() {
let def = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::Argv(vec!["echo".into(), "{pane_id}".into()]),
shell: false,
timeout_ms: HOOK_DEFAULT_TIMEOUT_MS,
};
validate(&def, 0).unwrap();
}
#[test]
fn dispatch_fires_only_matching_event() {
let dir = tempfile::tempdir().unwrap();
let touch = dir.path().join("client-attached.flag");
let other = dir.path().join("pane-died.flag");
let def_match = HookDef {
event: HookEvent::ClientAttached,
command: HookCommand::Argv(vec![
"/usr/bin/touch".into(),
touch.to_string_lossy().into_owned(),
]),
shell: false,
timeout_ms: 2000,
};
let def_other = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::Argv(vec![
"/usr/bin/touch".into(),
other.to_string_lossy().into_owned(),
]),
shell: false,
timeout_ms: 2000,
};
let mgr = HookManager::spawn(vec![def_match, def_other]);
mgr.dispatch(HookEvent::ClientAttached, vars_of(&[]));
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline && !touch.exists() {
std::thread::sleep(Duration::from_millis(20));
}
assert!(touch.exists(), "matching hook must run");
std::thread::sleep(Duration::from_millis(100));
assert!(!other.exists(), "non-matching hook must not run");
drop(mgr);
}
#[test]
fn timeout_kills_runaway_child() {
let def = HookDef {
event: HookEvent::PaneDied,
command: HookCommand::Argv(vec!["/bin/sleep".into(), "30".into()]),
shell: false,
timeout_ms: 200,
};
let mgr = HookManager::spawn(vec![def]);
let t0 = std::time::Instant::now();
mgr.dispatch(HookEvent::PaneDied, vars_of(&[]));
drop(mgr);
let elapsed = t0.elapsed();
assert!(
elapsed < Duration::from_secs(3),
"timeout must reap runaway child quickly (got {elapsed:?})"
);
}
}