libafl/monitors/tui/
mod.rs

1//! [`TuiMonitor`] is a fancy-looking TUI monitor similar to `AFL`.
2//!
3//! It's based on [ratatui](https://ratatui.rs/)
4
5use alloc::{
6    borrow::Cow,
7    boxed::Box,
8    collections::VecDeque,
9    string::{String, ToString},
10    sync::Arc,
11    vec::Vec,
12};
13use core::{fmt::Write as _, time::Duration};
14use std::{
15    io::{self, BufRead, Write},
16    panic,
17    sync::RwLock,
18    thread,
19    time::Instant,
20};
21
22use crossterm::{
23    cursor::{EnableBlinking, Show},
24    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
25    execute,
26    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
27};
28use hashbrown::HashMap;
29use libafl_bolts::{ClientId, Error, current_time, format_big_number, format_duration};
30use ratatui::{Terminal, backend::CrosstermBackend};
31use typed_builder::TypedBuilder;
32
33#[cfg(feature = "introspection")]
34use crate::monitors::stats::perf_stats::{ClientPerfStats, PerfFeature};
35use crate::monitors::{
36    Monitor,
37    stats::{
38        ClientStats, EdgeCoverage, ItemGeometry, ProcessTiming, manager::ClientStatsManager,
39        user_stats::UserStats,
40    },
41};
42
43#[expect(missing_docs)]
44pub mod ui;
45use ui::TuiUi;
46
47const DEFAULT_TIME_WINDOW: u64 = 60 * 10; // 10 min
48const DEFAULT_LOGS_NUMBER: usize = 128;
49
50#[derive(Debug, Clone, TypedBuilder)]
51#[builder(build_method(into = TuiMonitor), builder_method(vis = "pub(crate)",
52    doc = "Build the [`TuiMonitor`] from the set values"))]
53/// Settings to create a new [`TuiMonitor`].
54/// Use `TuiMonitor::builder()` or create this config and call `.into()` to create a new [`TuiMonitor`].
55pub struct TuiMonitorConfig {
56    /// The title to show
57    #[builder(default_code = r#""LibAFL Fuzzer".to_string()"#, setter(into))]
58    pub title: String,
59    /// A version string to show for this (optional)
60    #[builder(default_code = r#""default".to_string()"#, setter(into))]
61    pub version: String,
62    /// Enables unicode TUI graphics, Looks better but may interfere with old terminals.
63    #[builder(default = true)]
64    pub enhanced_graphics: bool,
65}
66
67/// A single status entry for timings
68#[derive(Debug, Copy, Clone)]
69pub struct TimedStat {
70    /// The time
71    pub time: Duration,
72    /// The item
73    pub item: u64,
74}
75
76/// Stats for timings
77#[derive(Debug, Clone)]
78pub struct TimedStats {
79    /// Series of [`TimedStat`] entries
80    pub series: VecDeque<TimedStat>,
81    /// The time window to keep track of
82    pub window: Duration,
83}
84
85impl TimedStats {
86    /// Create a new [`TimedStats`] struct
87    #[must_use]
88    pub fn new(window: Duration) -> Self {
89        Self {
90            series: VecDeque::new(),
91            window,
92        }
93    }
94
95    /// Add a stat datapoint
96    pub fn add(&mut self, time: Duration, item: u64) {
97        if self.series.is_empty() || self.series.back().unwrap().item != item {
98            if self.series.front().is_some()
99                && time - self.series.front().unwrap().time > self.window
100            {
101                self.series.pop_front();
102            }
103            self.series.push_back(TimedStat { time, item });
104        }
105    }
106
107    /// Add a stat datapoint for the `current_time`
108    pub fn add_now(&mut self, item: u64) {
109        if self.series.is_empty() || self.series[self.series.len() - 1].item != item {
110            let time = current_time();
111            if self.series.front().is_some()
112                && time - self.series.front().unwrap().time > self.window
113            {
114                self.series.pop_front();
115            }
116            self.series.push_back(TimedStat { time, item });
117        }
118    }
119
120    /// Change the window duration
121    pub fn update_window(&mut self, window: Duration) {
122        self.window = window;
123        while !self.series.is_empty()
124            && self.series.back().unwrap().time - self.series.front().unwrap().time > window
125        {
126            self.series.pop_front();
127        }
128    }
129}
130
131/// The context to show performance metrics
132#[cfg(feature = "introspection")]
133#[derive(Debug, Default, Clone)]
134pub struct PerfTuiContext {
135    /// Time spent in the scheduler
136    pub scheduler: f64,
137    /// Time spent in the event manager
138    pub manager: f64,
139    /// Additional time
140    pub unmeasured: f64,
141    /// Time spent in each individual stage
142    pub stages: Vec<Vec<(String, f64)>>,
143    /// Time spent in each individual feedback
144    pub feedbacks: Vec<(String, f64)>,
145}
146
147#[cfg(feature = "introspection")]
148impl PerfTuiContext {
149    /// Get the data for performance metrics
150    #[expect(clippy::cast_precision_loss)]
151    pub fn grab_data(&mut self, m: &ClientPerfStats) {
152        // Calculate the elapsed time from the monitor
153        let elapsed: f64 = m.elapsed_cycles() as f64;
154
155        // Calculate the percentages for each benchmark
156        self.scheduler = m.scheduler_cycles() as f64 / elapsed;
157        self.manager = m.manager_cycles() as f64 / elapsed;
158
159        // Calculate the remaining percentage that has not been benchmarked
160        let mut other_percent = 1.0;
161        other_percent -= self.scheduler;
162        other_percent -= self.manager;
163
164        self.stages.clear();
165
166        // Calculate each stage
167        // Make sure we only iterate over used stages
168        for (_stage_index, features) in m.used_stages() {
169            let mut features_percentages = vec![];
170
171            for (feature_index, feature) in features.iter().enumerate() {
172                // Calculate this current stage's percentage
173                let feature_percent = *feature as f64 / elapsed;
174
175                // Ignore this feature if it isn't used
176                if feature_percent == 0.0 {
177                    continue;
178                }
179
180                // Update the other percent by removing this current percent
181                other_percent -= feature_percent;
182
183                // Get the actual feature from the feature index for printing its name
184                let feature: PerfFeature = feature_index.into();
185                features_percentages.push((format!("{feature:?}"), feature_percent));
186            }
187
188            self.stages.push(features_percentages);
189        }
190
191        self.feedbacks.clear();
192
193        for (feedback_name, feedback_time) in m.feedbacks() {
194            // Calculate this current stage's percentage
195            let feedback_percent = *feedback_time as f64 / elapsed;
196
197            // Ignore this feedback if it isn't used
198            if feedback_percent == 0.0 {
199                continue;
200            }
201
202            // Update the other percent by removing this current percent
203            other_percent -= feedback_percent;
204
205            self.feedbacks
206                .push((feedback_name.clone(), feedback_percent));
207        }
208
209        self.unmeasured = other_percent;
210    }
211}
212
213/// The context for a single client tracked in this [`TuiMonitor`]
214#[derive(Debug, Default, Clone)]
215pub struct ClientTuiContext {
216    /// The corpus size
217    pub corpus: u64,
218    /// Amount of objectives
219    pub objectives: u64,
220    /// Amount of executions
221    pub executions: u64,
222    /// Float value formatted as String
223    pub map_density: String,
224
225    /// How many cycles have been done.
226    /// Roughly: every testcase has been scheduled once, but highly fuzzer-specific.
227    pub cycles_done: u64,
228
229    /// Times for processing
230    pub process_timing: ProcessTiming,
231    /// The individual entry geometry
232    pub item_geometry: ItemGeometry,
233    /// Extra fuzzer-specific stats
234    pub user_stats: HashMap<Cow<'static, str>, UserStats>,
235}
236
237impl ClientTuiContext {
238    /// Grab data for a single client
239    pub fn grab_data(&mut self, client: &mut ClientStats) {
240        self.corpus = client.corpus_size();
241        self.objectives = client.objective_size();
242        self.executions = client.executions();
243        self.process_timing = client.process_timing();
244
245        self.map_density = client.edges_coverage().map_or(
246            "0%".to_string(),
247            |EdgeCoverage {
248                 edges_hit,
249                 edges_total,
250             }| format!("{}%", edges_hit * 100 / edges_total),
251        );
252        self.item_geometry = client.item_geometry();
253
254        for (key, val) in client.user_stats() {
255            self.user_stats.insert(key.clone(), val.clone());
256        }
257    }
258}
259
260/// The [`TuiContext`] for this [`TuiMonitor`]
261#[derive(Debug, Clone)]
262#[expect(missing_docs)]
263pub struct TuiContext {
264    pub graphs: Vec<String>,
265
266    // TODO update the window using the UI key press events (+/-)
267    pub corpus_size_timed: TimedStats,
268    pub objective_size_timed: TimedStats,
269    pub execs_per_sec_timed: TimedStats,
270
271    #[cfg(feature = "introspection")]
272    pub introspection: HashMap<usize, PerfTuiContext>,
273
274    pub clients: HashMap<usize, ClientTuiContext>,
275
276    pub client_logs: VecDeque<String>,
277
278    pub clients_num: usize,
279    pub total_execs: u64,
280    pub start_time: Duration,
281
282    pub total_map_density: String,
283    pub total_solutions: u64,
284    pub total_corpus_count: u64,
285
286    pub total_process_timing: ProcessTiming,
287    pub total_item_geometry: ItemGeometry,
288}
289
290impl TuiContext {
291    /// Create a new TUI context
292    #[must_use]
293    pub fn new(start_time: Duration) -> Self {
294        Self {
295            graphs: vec!["corpus".into(), "objectives".into(), "exec/sec".into()],
296            corpus_size_timed: TimedStats::new(Duration::from_secs(DEFAULT_TIME_WINDOW)),
297            objective_size_timed: TimedStats::new(Duration::from_secs(DEFAULT_TIME_WINDOW)),
298            execs_per_sec_timed: TimedStats::new(Duration::from_secs(DEFAULT_TIME_WINDOW)),
299
300            #[cfg(feature = "introspection")]
301            introspection: HashMap::default(),
302            clients: HashMap::default(),
303
304            client_logs: VecDeque::with_capacity(DEFAULT_LOGS_NUMBER),
305
306            clients_num: 0,
307            total_execs: 0,
308            start_time,
309
310            total_map_density: "0%".to_string(),
311            total_solutions: 0,
312            total_corpus_count: 0,
313            total_item_geometry: ItemGeometry::new(),
314            total_process_timing: ProcessTiming::new(),
315        }
316    }
317}
318
319/// Tracking monitor during fuzzing and display with [`ratatui`](https://ratatui.rs/)
320#[derive(Debug, Clone)]
321pub struct TuiMonitor {
322    pub(crate) context: Arc<RwLock<TuiContext>>,
323}
324
325impl From<TuiMonitorConfig> for TuiMonitor {
326    #[expect(deprecated)]
327    fn from(builder: TuiMonitorConfig) -> Self {
328        Self::with_time(
329            TuiUi::with_version(builder.title, builder.version, builder.enhanced_graphics),
330            current_time(),
331        )
332    }
333}
334
335impl Monitor for TuiMonitor {
336    #[expect(clippy::cast_sign_loss)]
337    fn display(
338        &mut self,
339        client_stats_manager: &mut ClientStatsManager,
340        event_msg: &str,
341        sender_id: ClientId,
342    ) -> Result<(), Error> {
343        let cur_time = current_time();
344
345        {
346            let global_stats = client_stats_manager.global_stats();
347            // TODO implement floating-point support for TimedStat
348            let execsec = global_stats.execs_per_sec as u64;
349            let totalexec = global_stats.total_execs;
350            let run_time = global_stats.run_time;
351            let exec_per_sec_pretty = global_stats.execs_per_sec_pretty.clone();
352            let total_execs = global_stats.total_execs;
353            let mut ctx = self.context.write().unwrap();
354            ctx.total_corpus_count = global_stats.corpus_size;
355            ctx.total_solutions = global_stats.objective_size;
356            ctx.corpus_size_timed
357                .add(run_time, global_stats.corpus_size);
358            ctx.objective_size_timed
359                .add(run_time, global_stats.objective_size);
360            let total_process_timing =
361                client_stats_manager.process_timing(exec_per_sec_pretty, total_execs);
362
363            ctx.total_process_timing = total_process_timing;
364            ctx.execs_per_sec_timed.add(run_time, execsec);
365            ctx.start_time = client_stats_manager.start_time();
366            ctx.total_execs = totalexec;
367            ctx.clients_num = client_stats_manager.client_stats().len();
368            ctx.total_map_density = client_stats_manager.edges_coverage().map_or(
369                "0%".to_string(),
370                |EdgeCoverage {
371                     edges_hit,
372                     edges_total,
373                 }| format!("{}%", edges_hit * 100 / edges_total),
374            );
375            ctx.total_item_geometry = client_stats_manager.item_geometry();
376        }
377
378        client_stats_manager.client_stats_insert(sender_id)?;
379        let exec_sec = client_stats_manager
380            .update_client_stats_for(sender_id, |client| client.execs_per_sec_pretty(cur_time))?;
381        let client = client_stats_manager.client_stats_for(sender_id)?;
382
383        let sender = format!("#{}", sender_id.0);
384        let pad = if event_msg.len() + sender.len() < 13 {
385            " ".repeat(13 - event_msg.len() - sender.len())
386        } else {
387            String::new()
388        };
389        let head = format!("{event_msg}{pad} {sender}");
390        let mut fmt = format!(
391            "[{}] corpus: {}, objectives: {}, executions: {}, exec/sec: {}",
392            head,
393            format_big_number(client.corpus_size()),
394            format_big_number(client.objective_size()),
395            format_big_number(client.executions()),
396            exec_sec
397        );
398        for (key, val) in client.user_stats() {
399            write!(fmt, ", {key}: {val}").unwrap();
400        }
401        for (key, val) in client_stats_manager.aggregated() {
402            write!(fmt, ", {key}: {val}").unwrap();
403        }
404
405        {
406            let mut ctx = self.context.write().unwrap();
407            client_stats_manager.update_client_stats_for(sender_id, |client| {
408                ctx.clients
409                    .entry(sender_id.0 as usize)
410                    .or_default()
411                    .grab_data(client);
412            })?;
413            while ctx.client_logs.len() >= DEFAULT_LOGS_NUMBER {
414                ctx.client_logs.pop_front();
415            }
416            ctx.client_logs.push_back(fmt);
417        }
418
419        #[cfg(feature = "introspection")]
420        {
421            // Print the client performance monitor. Skip the Client IDs that have never sent anything.
422            for (i, (_, client)) in client_stats_manager
423                .client_stats()
424                .iter()
425                .filter(|(_, x)| x.enabled())
426                .enumerate()
427            {
428                self.context
429                    .write()
430                    .unwrap()
431                    .introspection
432                    .entry(i + 1)
433                    .or_default()
434                    .grab_data(&client.introspection_stats);
435            }
436        }
437
438        Ok(())
439    }
440}
441
442impl TuiMonitor {
443    /// Create a builder for [`TuiMonitor`]
444    pub fn builder() -> TuiMonitorConfigBuilder {
445        TuiMonitorConfig::builder()
446    }
447
448    /// Creates the monitor.
449    ///
450    /// # Deprecation Note
451    /// Use `TuiMonitor::builder()` instead.
452    #[deprecated(
453        since = "0.13.2",
454        note = "Please use TuiMonitor::builder() instead of creating TuiUi directly."
455    )]
456    #[must_use]
457    #[expect(deprecated)]
458    pub fn new(tui_ui: TuiUi) -> Self {
459        Self::with_time(tui_ui, current_time())
460    }
461
462    /// Creates the monitor with a given `start_time`.
463    ///
464    /// # Deprecation Note
465    /// Use `TuiMonitor::builder()` instead.
466    #[deprecated(
467        since = "0.13.2",
468        note = "Please use TuiMonitor::builder() instead of creating TuiUi directly."
469    )]
470    #[must_use]
471    pub fn with_time(tui_ui: TuiUi, start_time: Duration) -> Self {
472        let context = Arc::new(RwLock::new(TuiContext::new(start_time)));
473
474        enable_raw_mode().unwrap();
475        #[cfg(unix)]
476        {
477            use std::{
478                fs::File,
479                os::fd::{AsRawFd, FromRawFd},
480            };
481
482            let stdout = unsafe { libc::dup(io::stdout().as_raw_fd()) };
483            let stdout = unsafe { File::from_raw_fd(stdout) };
484            run_tui_thread(
485                context.clone(),
486                Duration::from_millis(250),
487                tui_ui,
488                move || stdout.try_clone().unwrap(),
489            );
490        }
491        #[cfg(not(unix))]
492        {
493            run_tui_thread(
494                context.clone(),
495                Duration::from_millis(250),
496                tui_ui,
497                io::stdout,
498            );
499        }
500        Self { context }
501    }
502}
503
504fn run_tui_thread<W: Write + Send + Sync + 'static>(
505    context: Arc<RwLock<TuiContext>>,
506    tick_rate: Duration,
507    tui_ui: TuiUi,
508    stdout_provider: impl Send + Sync + 'static + Fn() -> W,
509) {
510    thread::spawn(move || -> io::Result<()> {
511        // setup terminal
512        let mut stdout = stdout_provider();
513        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
514
515        let backend = CrosstermBackend::new(stdout);
516        let mut terminal = Terminal::new(backend)?;
517
518        let mut ui = tui_ui;
519
520        let mut last_tick = Instant::now();
521        let mut cnt = 0;
522
523        // Catching panics when the main thread dies
524        let old_hook = panic::take_hook();
525        panic::set_hook(Box::new(move |panic_info| {
526            let mut stdout = stdout_provider();
527            disable_raw_mode().unwrap();
528            execute!(
529                stdout,
530                LeaveAlternateScreen,
531                DisableMouseCapture,
532                Show,
533                EnableBlinking,
534            )
535            .unwrap();
536            old_hook(panic_info);
537        }));
538
539        loop {
540            // to avoid initial ui glitches
541            if cnt < 8 {
542                drop(terminal.clear());
543                cnt += 1;
544            }
545            terminal.draw(|f| ui.draw(f, &context))?;
546
547            let timeout = tick_rate
548                .checked_sub(last_tick.elapsed())
549                .unwrap_or_else(|| Duration::from_secs(0));
550            if event::poll(timeout)? {
551                if let Event::Key(key) = event::read()? {
552                    match key.code {
553                        KeyCode::Char(c) => ui.on_key(c),
554                        KeyCode::Left => ui.on_left(),
555                        //KeyCode::Up => ui.on_up(),
556                        KeyCode::Right => ui.on_right(),
557                        //KeyCode::Down => ui.on_down(),
558                        _ => {}
559                    }
560                }
561            }
562            if last_tick.elapsed() >= tick_rate {
563                //context.on_tick();
564                last_tick = Instant::now();
565            }
566            if ui.should_quit {
567                // restore terminal
568                disable_raw_mode()?;
569                execute!(
570                    terminal.backend_mut(),
571                    LeaveAlternateScreen,
572                    DisableMouseCapture
573                )?;
574                terminal.show_cursor()?;
575
576                println!(
577                    "\nPress Control-C to stop the fuzzers, otherwise press Enter to resume the visualization\n"
578                );
579
580                let mut line = String::new();
581                io::stdin().lock().read_line(&mut line)?;
582
583                // setup terminal
584                let mut stdout = io::stdout();
585                enable_raw_mode()?;
586                execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
587
588                cnt = 0;
589                ui.should_quit = false;
590            }
591        }
592    });
593}