1use 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; const 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"))]
53pub struct TuiMonitorConfig {
56 #[builder(default_code = r#""LibAFL Fuzzer".to_string()"#, setter(into))]
58 pub title: String,
59 #[builder(default_code = r#""default".to_string()"#, setter(into))]
61 pub version: String,
62 #[builder(default = true)]
64 pub enhanced_graphics: bool,
65}
66
67#[derive(Debug, Copy, Clone)]
69pub struct TimedStat {
70 pub time: Duration,
72 pub item: u64,
74}
75
76#[derive(Debug, Clone)]
78pub struct TimedStats {
79 pub series: VecDeque<TimedStat>,
81 pub window: Duration,
83}
84
85impl TimedStats {
86 #[must_use]
88 pub fn new(window: Duration) -> Self {
89 Self {
90 series: VecDeque::new(),
91 window,
92 }
93 }
94
95 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 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 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#[cfg(feature = "introspection")]
133#[derive(Debug, Default, Clone)]
134pub struct PerfTuiContext {
135 pub scheduler: f64,
137 pub manager: f64,
139 pub unmeasured: f64,
141 pub stages: Vec<Vec<(String, f64)>>,
143 pub feedbacks: Vec<(String, f64)>,
145}
146
147#[cfg(feature = "introspection")]
148impl PerfTuiContext {
149 #[expect(clippy::cast_precision_loss)]
151 pub fn grab_data(&mut self, m: &ClientPerfStats) {
152 let elapsed: f64 = m.elapsed_cycles() as f64;
154
155 self.scheduler = m.scheduler_cycles() as f64 / elapsed;
157 self.manager = m.manager_cycles() as f64 / elapsed;
158
159 let mut other_percent = 1.0;
161 other_percent -= self.scheduler;
162 other_percent -= self.manager;
163
164 self.stages.clear();
165
166 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 let feature_percent = *feature as f64 / elapsed;
174
175 if feature_percent == 0.0 {
177 continue;
178 }
179
180 other_percent -= feature_percent;
182
183 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 let feedback_percent = *feedback_time as f64 / elapsed;
196
197 if feedback_percent == 0.0 {
199 continue;
200 }
201
202 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#[derive(Debug, Default, Clone)]
215pub struct ClientTuiContext {
216 pub corpus: u64,
218 pub objectives: u64,
220 pub executions: u64,
222 pub map_density: String,
224
225 pub cycles_done: u64,
228
229 pub process_timing: ProcessTiming,
231 pub item_geometry: ItemGeometry,
233 pub user_stats: HashMap<Cow<'static, str>, UserStats>,
235}
236
237impl ClientTuiContext {
238 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#[derive(Debug, Clone)]
262#[expect(missing_docs)]
263pub struct TuiContext {
264 pub graphs: Vec<String>,
265
266 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 #[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#[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 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 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 pub fn builder() -> TuiMonitorConfigBuilder {
445 TuiMonitorConfig::builder()
446 }
447
448 #[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 #[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 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 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 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::Right => ui.on_right(),
557 _ => {}
559 }
560 }
561 }
562 if last_tick.elapsed() >= tick_rate {
563 last_tick = Instant::now();
565 }
566 if ui.should_quit {
567 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 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}