use alloc::{
borrow::Cow,
boxed::Box,
collections::VecDeque,
string::{String, ToString},
sync::Arc,
vec::Vec,
};
use core::{fmt::Write as _, time::Duration};
use std::{
io::{self, BufRead, Write},
panic,
sync::RwLock,
thread,
time::Instant,
};
use crossterm::{
cursor::{EnableBlinking, Show},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use hashbrown::HashMap;
use libafl_bolts::{ClientId, Error, current_time, format_big_number, format_duration};
use ratatui::{Terminal, backend::CrosstermBackend};
use typed_builder::TypedBuilder;
#[cfg(feature = "introspection")]
use crate::monitors::stats::perf_stats::{ClientPerfStats, PerfFeature};
use crate::monitors::{
Monitor,
stats::{
ClientStats, EdgeCoverage, ItemGeometry, ProcessTiming, manager::ClientStatsManager,
user_stats::UserStats,
},
};
#[expect(missing_docs)]
pub mod ui;
use ui::TuiUi;
const DEFAULT_TIME_WINDOW: u64 = 60 * 10; const DEFAULT_LOGS_NUMBER: usize = 128;
#[derive(Debug, Clone, TypedBuilder)]
#[builder(build_method(into = TuiMonitor), builder_method(vis = "pub(crate)",
doc = "Build the [`TuiMonitor`] from the set values"))]
pub struct TuiMonitorConfig {
#[builder(default_code = r#""LibAFL Fuzzer".to_string()"#, setter(into))]
pub title: String,
#[builder(default_code = r#""default".to_string()"#, setter(into))]
pub version: String,
#[builder(default = true)]
pub enhanced_graphics: bool,
}
#[derive(Debug, Copy, Clone)]
pub struct TimedStat {
pub time: Duration,
pub item: u64,
}
#[derive(Debug, Clone)]
pub struct TimedStats {
pub series: VecDeque<TimedStat>,
pub window: Duration,
}
impl TimedStats {
#[must_use]
pub fn new(window: Duration) -> Self {
Self {
series: VecDeque::new(),
window,
}
}
pub fn add(&mut self, time: Duration, item: u64) {
if self.series.is_empty() || self.series.back().unwrap().item != item {
if self.series.front().is_some()
&& time
.checked_sub(self.series.front().unwrap().time)
.unwrap_or(self.window)
>= self.window
{
self.series.pop_front();
}
self.series.push_back(TimedStat { time, item });
}
}
pub fn add_now(&mut self, item: u64) {
if self.series.is_empty() || self.series[self.series.len() - 1].item != item {
let time = current_time();
if self.series.front().is_some()
&& time
.checked_sub(self.series.front().unwrap().time)
.unwrap_or(self.window)
>= self.window
{
self.series.pop_front();
}
self.series.push_back(TimedStat { time, item });
}
}
pub fn update_window(&mut self, window: Duration) {
let default_stat = TimedStat {
time: Duration::from_secs(0),
item: 0,
};
self.window = window;
while !self.series.is_empty()
&& self
.series
.back()
.unwrap_or(&default_stat)
.time
.checked_sub(self.series.front().unwrap_or(&default_stat).time)
.unwrap()
>= window
{
self.series.pop_front();
}
}
}
#[cfg(feature = "introspection")]
#[derive(Debug, Default, Clone)]
pub struct PerfTuiContext {
pub scheduler: f64,
pub manager: f64,
pub unmeasured: f64,
pub stages: Vec<Vec<(String, f64)>>,
pub feedbacks: Vec<(String, f64)>,
}
#[cfg(feature = "introspection")]
impl PerfTuiContext {
#[expect(clippy::cast_precision_loss)]
pub fn grab_data(&mut self, m: &ClientPerfStats) {
let elapsed: f64 = m.elapsed_cycles() as f64;
self.scheduler = m.scheduler_cycles() as f64 / elapsed;
self.manager = m.manager_cycles() as f64 / elapsed;
let mut other_percent = 1.0;
other_percent -= self.scheduler;
other_percent -= self.manager;
self.stages.clear();
for (_stage_index, features) in m.used_stages() {
let mut features_percentages = vec![];
for (feature_index, feature) in features.iter().enumerate() {
let feature_percent = *feature as f64 / elapsed;
if feature_percent == 0.0 {
continue;
}
other_percent -= feature_percent;
let feature: PerfFeature = feature_index.into();
features_percentages.push((format!("{feature:?}"), feature_percent));
}
self.stages.push(features_percentages);
}
self.feedbacks.clear();
for (feedback_name, feedback_time) in m.feedbacks() {
let feedback_percent = *feedback_time as f64 / elapsed;
if feedback_percent == 0.0 {
continue;
}
other_percent -= feedback_percent;
self.feedbacks
.push((feedback_name.clone(), feedback_percent));
}
self.unmeasured = other_percent;
}
}
#[derive(Debug, Default, Clone)]
pub struct ClientTuiContext {
pub corpus: u64,
pub objectives: u64,
pub executions: u64,
pub map_density: String,
pub cycles_done: u64,
pub process_timing: ProcessTiming,
pub item_geometry: ItemGeometry,
pub user_stats: HashMap<Cow<'static, str>, UserStats>,
}
impl ClientTuiContext {
pub fn grab_data(&mut self, client: &mut ClientStats) {
self.corpus = client.corpus_size();
self.objectives = client.objective_size();
self.executions = client.executions();
self.process_timing = client.process_timing();
self.map_density = client.edges_coverage().map_or(
"0%".to_string(),
|EdgeCoverage {
edges_hit,
edges_total,
}| format!("{}%", edges_hit * 100 / edges_total),
);
self.item_geometry = client.item_geometry();
for (key, val) in client.user_stats() {
self.user_stats.insert(key.clone(), val.clone());
}
}
}
#[derive(Debug, Clone)]
#[expect(missing_docs)]
pub struct TuiContext {
pub graphs: Vec<String>,
pub corpus_size_timed: TimedStats,
pub objective_size_timed: TimedStats,
pub execs_per_sec_timed: TimedStats,
#[cfg(feature = "introspection")]
pub introspection: HashMap<usize, PerfTuiContext>,
pub clients: HashMap<usize, ClientTuiContext>,
pub client_logs: VecDeque<String>,
pub clients_num: usize,
pub total_execs: u64,
pub start_time: Duration,
pub total_map_density: String,
pub total_solutions: u64,
pub total_corpus_count: u64,
pub total_process_timing: ProcessTiming,
pub total_item_geometry: ItemGeometry,
}
impl TuiContext {
#[must_use]
pub fn new(start_time: Duration) -> Self {
Self {
graphs: vec!["corpus".into(), "objectives".into(), "exec/sec".into()],
corpus_size_timed: TimedStats::new(Duration::from_secs(DEFAULT_TIME_WINDOW)),
objective_size_timed: TimedStats::new(Duration::from_secs(DEFAULT_TIME_WINDOW)),
execs_per_sec_timed: TimedStats::new(Duration::from_secs(DEFAULT_TIME_WINDOW)),
#[cfg(feature = "introspection")]
introspection: HashMap::default(),
clients: HashMap::default(),
client_logs: VecDeque::with_capacity(DEFAULT_LOGS_NUMBER),
clients_num: 0,
total_execs: 0,
start_time,
total_map_density: "0%".to_string(),
total_solutions: 0,
total_corpus_count: 0,
total_item_geometry: ItemGeometry::new(),
total_process_timing: ProcessTiming::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct TuiMonitor {
pub(crate) context: Arc<RwLock<TuiContext>>,
}
impl From<TuiMonitorConfig> for TuiMonitor {
#[expect(deprecated)]
fn from(builder: TuiMonitorConfig) -> Self {
Self::with_time(
TuiUi::with_version(builder.title, builder.version, builder.enhanced_graphics),
current_time(),
)
}
}
impl Monitor for TuiMonitor {
#[expect(clippy::cast_sign_loss)]
fn display(
&mut self,
client_stats_manager: &mut ClientStatsManager,
event_msg: &str,
sender_id: ClientId,
) -> Result<(), Error> {
let cur_time = current_time();
{
let global_stats = client_stats_manager.global_stats();
let execsec = global_stats.execs_per_sec as u64;
let totalexec = global_stats.total_execs;
let run_time = global_stats.run_time;
let exec_per_sec_pretty = global_stats.execs_per_sec_pretty.clone();
let total_execs = global_stats.total_execs;
let mut ctx = self.context.write().unwrap();
ctx.total_corpus_count = global_stats.corpus_size;
ctx.total_solutions = global_stats.objective_size;
ctx.corpus_size_timed
.add(run_time, global_stats.corpus_size);
ctx.objective_size_timed
.add(run_time, global_stats.objective_size);
let total_process_timing =
client_stats_manager.process_timing(exec_per_sec_pretty, total_execs);
ctx.total_process_timing = total_process_timing;
ctx.execs_per_sec_timed.add(run_time, execsec);
ctx.start_time = client_stats_manager.start_time();
ctx.total_execs = totalexec;
ctx.clients_num = client_stats_manager.client_stats().len();
ctx.total_map_density = client_stats_manager.edges_coverage().map_or(
"0%".to_string(),
|EdgeCoverage {
edges_hit,
edges_total,
}| format!("{}%", edges_hit * 100 / edges_total),
);
ctx.total_item_geometry = client_stats_manager.item_geometry();
}
client_stats_manager.client_stats_insert(sender_id)?;
let exec_sec = client_stats_manager
.update_client_stats_for(sender_id, |client| client.execs_per_sec_pretty(cur_time))?;
let client = client_stats_manager.client_stats_for(sender_id)?;
let sender = format!("#{}", sender_id.0);
let pad = if event_msg.len() + sender.len() < 13 {
" ".repeat(13 - event_msg.len() - sender.len())
} else {
String::new()
};
let head = format!("{event_msg}{pad} {sender}");
let mut fmt = format!(
"[{}] corpus: {}, objectives: {}, executions: {}, exec/sec: {}",
head,
format_big_number(client.corpus_size()),
format_big_number(client.objective_size()),
format_big_number(client.executions()),
exec_sec
);
for (key, val) in client.user_stats() {
write!(fmt, ", {key}: {val}").unwrap();
}
for (key, val) in client_stats_manager.aggregated() {
write!(fmt, ", {key}: {val}").unwrap();
}
{
let mut ctx = self.context.write().unwrap();
client_stats_manager.update_client_stats_for(sender_id, |client| {
ctx.clients
.entry(sender_id.0 as usize)
.or_default()
.grab_data(client);
})?;
while ctx.client_logs.len() >= DEFAULT_LOGS_NUMBER {
ctx.client_logs.pop_front();
}
ctx.client_logs.push_back(fmt);
}
#[cfg(feature = "introspection")]
{
for (i, (_, client)) in client_stats_manager
.client_stats()
.iter()
.filter(|(_, x)| x.enabled())
.enumerate()
{
self.context
.write()
.unwrap()
.introspection
.entry(i + 1)
.or_default()
.grab_data(&client.introspection_stats);
}
}
Ok(())
}
}
impl TuiMonitor {
pub fn builder() -> TuiMonitorConfigBuilder {
TuiMonitorConfig::builder()
}
#[deprecated(
since = "0.13.2",
note = "Please use TuiMonitor::builder() instead of creating TuiUi directly."
)]
#[must_use]
#[expect(deprecated)]
pub fn new(tui_ui: TuiUi) -> Self {
Self::with_time(tui_ui, current_time())
}
#[deprecated(
since = "0.13.2",
note = "Please use TuiMonitor::builder() instead of creating TuiUi directly."
)]
#[must_use]
pub fn with_time(tui_ui: TuiUi, start_time: Duration) -> Self {
let context = Arc::new(RwLock::new(TuiContext::new(start_time)));
enable_raw_mode().unwrap();
#[cfg(unix)]
{
use std::{
fs::File,
os::fd::{AsRawFd, FromRawFd},
};
let stdout = unsafe { libc::dup(io::stdout().as_raw_fd()) };
let stdout = unsafe { File::from_raw_fd(stdout) };
run_tui_thread(
context.clone(),
Duration::from_millis(250),
tui_ui,
move || stdout.try_clone().unwrap(),
);
}
#[cfg(not(unix))]
{
run_tui_thread(
context.clone(),
Duration::from_millis(250),
tui_ui,
io::stdout,
);
}
Self { context }
}
}
fn run_tui_thread<W: Write + Send + Sync + 'static>(
context: Arc<RwLock<TuiContext>>,
tick_rate: Duration,
tui_ui: TuiUi,
stdout_provider: impl Send + Sync + 'static + Fn() -> W,
) {
thread::spawn(move || -> io::Result<()> {
let mut stdout = stdout_provider();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut ui = tui_ui;
let mut last_tick = Instant::now();
let mut cnt = 0;
let old_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let mut stdout = stdout_provider();
disable_raw_mode().unwrap();
execute!(
stdout,
LeaveAlternateScreen,
DisableMouseCapture,
Show,
EnableBlinking,
)
.unwrap();
old_hook(panic_info);
}));
loop {
if cnt < 8 {
drop(terminal.clear());
cnt += 1;
}
terminal.draw(|f| ui.draw(f, &context))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char(c) => ui.on_key(c),
KeyCode::Left => ui.on_left(),
KeyCode::Right => ui.on_right(),
_ => {}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
if ui.should_quit {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
println!(
"\nPress Control-C to stop the fuzzers, otherwise press Enter to resume the visualization\n"
);
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
cnt = 0;
ui.should_quit = false;
}
}
});
}