Skip to content

Commit 92578e1

Browse files
committed
perf(tui): optimize render complexity to O(1) and fix 100% CPU lock during scans
- Fix 100% CPU core utilization (100.3% CPU in htop) caused by unthrottled per-frame O(42N) iterations over scan logs in the UI renderer. - Fix UI freezes, stuck progress counters, and buffered keyboard input latency during large CIDR scans. - Introduce incremental HashMap indexing (app.port_stats and filtered_log_indices) for instantaneous O(1) TUI frame rendering. - Add 60 FPS frame rate cap (16ms poll timeout) and batch background event channel drains (2000 events/frame) for smooth input handling. - Bump version to 1.4.6.
1 parent bedf2a9 commit 92578e1

8 files changed

Lines changed: 143 additions & 114 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ampscan"
3-
version = "1.4.5"
3+
version = "1.4.6"
44
edition = "2021"
55
description = "DDoS Amplification Port Testing Tool with TUI"
66
authors = ["Marcelo Gondim"]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,5 +266,5 @@ Below is the complete list of standard command-line instructions to manually and
266266
This repository supports build and test automation via **Woodpecker CI** hosted on Codeberg:
267267

268268
* **Continuous Integration (CI):** On every `push` or `pull_request` sent to the `main` branch, the complete unit and integration test suite is executed automatically (with parallelism limited to `-j 1` to respect Codeberg's shared resource guidelines).
269-
* **Continuous Delivery (CD):** When creating and pushing a version tag (e.g., `v1.4.5`), the pipeline compiles the binary (`ampscan`) in production mode (Release) for Linux x86_64, compresses it into a `.tar.gz` file, and attaches the final file directly to the Releases page on Codeberg.
269+
* **Continuous Delivery (CD):** When creating and pushing a version tag (e.g., `v1.4.6`), the pipeline compiles the binary (`ampscan`) in production mode (Release) for Linux x86_64, compresses it into a `.tar.gz` file, and attaches the final file directly to the Releases page on Codeberg.
270270

TECH_STACK.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Technology Stack & Architecture — AmpScan
22

3-
This document provides a comprehensive overview of the technology stack, external crates, system libraries, and architectural decisions powering **ampscan** (v1.4.5).
3+
This document provides a comprehensive overview of the technology stack, external crates, system libraries, and architectural decisions powering **ampscan** (v1.4.6).
44

55
---
66

src/tui/app.rs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub struct App {
7171
// Scan Data & Logs
7272
pub stats: ScanStats,
7373
pub logs: Vec<ProbeResult>,
74+
pub filtered_log_indices: Vec<usize>,
75+
pub port_stats: std::collections::HashMap<u16, (usize, usize)>,
7476
pub selected_log_index: usize,
7577

7678
// DB Data Cache for TUI
@@ -116,6 +118,8 @@ impl App {
116118
is_scanning: false,
117119
stats: ScanStats::default(),
118120
logs: Vec::new(),
121+
filtered_log_indices: Vec::new(),
122+
port_stats: std::collections::HashMap::new(),
119123
selected_log_index: 0,
120124
prefixes: Vec::new(),
121125
ports: Vec::new(),
@@ -133,7 +137,7 @@ impl App {
133137
pdf_output,
134138
pdf_client_name,
135139
pdf_recipient,
136-
status_message: Some("Ready - Press [S] to start scanning target".to_string()),
140+
status_message: None,
137141
}
138142
}
139143

@@ -153,23 +157,45 @@ impl App {
153157
self.active_tab = Tab::ALL[prev_idx];
154158
}
155159

160+
pub fn clear_scan_data(&mut self) {
161+
self.logs.clear();
162+
self.filtered_log_indices.clear();
163+
self.port_stats.clear();
164+
self.selected_log_index = 0;
165+
self.stats = ScanStats::default();
166+
self.stats.start_time = Some(std::time::Instant::now());
167+
}
168+
156169
pub fn get_filtered_findings_count(&self) -> usize {
157-
self.logs
158-
.iter()
159-
.filter(|log| {
160-
log.status == crate::scanner::result::PortStatus::Open
161-
|| log.status == crate::scanner::result::PortStatus::OpenProtected
162-
})
163-
.count()
170+
self.filtered_log_indices.len()
164171
}
165172

166173
pub fn add_probe_result(&mut self, result: ProbeResult) {
174+
let is_finding = matches!(
175+
result.status,
176+
crate::scanner::result::PortStatus::Open | crate::scanner::result::PortStatus::OpenProtected
177+
);
178+
167179
match &result.status {
168-
crate::scanner::result::PortStatus::Open => self.stats.vulnerable_count += 1,
169-
crate::scanner::result::PortStatus::OpenProtected => self.stats.protected_count += 1,
180+
crate::scanner::result::PortStatus::Open => {
181+
self.stats.vulnerable_count += 1;
182+
let entry = self.port_stats.entry(result.port).or_default();
183+
entry.0 += 1;
184+
}
185+
crate::scanner::result::PortStatus::OpenProtected => {
186+
self.stats.protected_count += 1;
187+
let entry = self.port_stats.entry(result.port).or_default();
188+
entry.1 += 1;
189+
}
170190
_ => self.stats.closed_count += 1,
171191
}
172192
self.stats.completed_probes += 1;
193+
194+
let new_idx = self.logs.len();
173195
self.logs.push(result);
196+
197+
if is_finding {
198+
self.filtered_log_indices.push(new_idx);
199+
}
174200
}
175201
}

src/tui/events.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub enum UserAction {
1010
}
1111

1212
pub fn handle_events(app: &mut App) -> io::Result<UserAction> {
13-
if event::poll(Duration::from_millis(50))? {
13+
if event::poll(Duration::from_millis(16))? {
1414
if let Event::Key(key) = event::read()? {
1515
if key.kind == event::KeyEventKind::Press {
1616
// Handle text input on SingleTarget and Settings tabs

src/tui/mod.rs

Lines changed: 67 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -63,70 +63,77 @@ pub async fn run_tui(
6363

6464
// Main TUI Event Loop
6565
loop {
66-
// Drain any incoming scan events from background task
67-
while let Ok(tui_event) = rx.try_recv() {
68-
match tui_event {
69-
TuiEvent::ScanStarted(total) => {
70-
app.stats.total_probes = total;
71-
}
72-
TuiEvent::ProbeCompleted(res) => {
73-
app.add_probe_result(res);
74-
}
75-
TuiEvent::ScanFinished => {
76-
app.is_scanning = false;
77-
78-
// If --pdf flag was supplied via CLI, generate PDF report automatically
79-
if app.pdf_export {
80-
let scan_id = uuid::Uuid::new_v4().to_string();
81-
let prefixes: Vec<String> = app.prefixes.iter().map(|p| p.prefix.clone()).collect();
82-
let mut report = crate::scanner::result::ScanReport::new(scan_id, prefixes);
83-
84-
// Calculate total unique IPs tested from logs
85-
let mut unique_ips: Vec<_> = app.logs.iter().map(|l| l.ip).collect();
86-
unique_ips.sort();
87-
unique_ips.dedup();
88-
89-
report.total_ips = unique_ips.len();
90-
report.total_probes = app.stats.total_probes.max(app.logs.len());
91-
report.results = app.logs.clone();
92-
93-
// Preserve start time if available
94-
if let Some(start_inst) = app.stats.start_time {
95-
let elapsed = start_inst.elapsed();
96-
report.started_at = chrono::Utc::now() - chrono::Duration::from_std(elapsed).unwrap_or_default();
66+
// Drain incoming scan events from background task in batches to ensure smooth 60 FPS rendering
67+
let mut processed = 0;
68+
while processed < 2000 {
69+
match rx.try_recv() {
70+
Ok(tui_event) => {
71+
processed += 1;
72+
match tui_event {
73+
TuiEvent::ScanStarted(total) => {
74+
app.stats.total_probes = total;
75+
}
76+
TuiEvent::ProbeCompleted(res) => {
77+
app.add_probe_result(res);
9778
}
98-
99-
report.finalize();
79+
TuiEvent::ScanFinished => {
80+
app.is_scanning = false;
10081

101-
let app_config = crate::report::AppConfig::load();
102-
match crate::report::generate_pdf(
103-
&report,
104-
&app.pdf_output,
105-
app.pdf_client_name.as_deref(),
106-
app.pdf_recipient.as_deref(),
107-
&app_config,
108-
) {
109-
Ok(_) => {
110-
app.status_message = Some(format!(
111-
"Scan completed! PDF report generated: {}",
112-
app.pdf_output
113-
));
114-
}
115-
Err(e) => {
116-
app.status_message = Some(format!(
117-
"Scan completed! PDF report error: {}",
118-
e
119-
));
82+
// If --pdf flag was supplied via CLI, generate PDF report automatically
83+
if app.pdf_export {
84+
let scan_id = uuid::Uuid::new_v4().to_string();
85+
let prefixes: Vec<String> = app.prefixes.iter().map(|p| p.prefix.clone()).collect();
86+
let mut report = crate::scanner::result::ScanReport::new(scan_id, prefixes);
87+
88+
// Calculate total unique IPs tested from logs
89+
let mut unique_ips: Vec<_> = app.logs.iter().map(|l| l.ip).collect();
90+
unique_ips.sort();
91+
unique_ips.dedup();
92+
93+
report.total_ips = unique_ips.len();
94+
report.total_probes = app.stats.total_probes.max(app.logs.len());
95+
report.results = app.logs.clone();
96+
97+
// Preserve start time if available
98+
if let Some(start_inst) = app.stats.start_time {
99+
let elapsed = start_inst.elapsed();
100+
report.started_at = chrono::Utc::now() - chrono::Duration::from_std(elapsed).unwrap_or_default();
101+
}
102+
103+
report.finalize();
104+
105+
let app_config = crate::report::AppConfig::load();
106+
match crate::report::generate_pdf(
107+
&report,
108+
&app.pdf_output,
109+
app.pdf_client_name.as_deref(),
110+
app.pdf_recipient.as_deref(),
111+
&app_config,
112+
) {
113+
Ok(_) => {
114+
app.status_message = Some(format!(
115+
"Scan completed! PDF report generated: {}",
116+
app.pdf_output
117+
));
118+
}
119+
Err(e) => {
120+
app.status_message = Some(format!(
121+
"Scan completed! PDF report error: {}",
122+
e
123+
));
124+
}
125+
}
126+
} else {
127+
app.status_message = Some("Scan completed!".to_string());
120128
}
121129
}
122-
} else {
123-
app.status_message = Some("Scan completed!".to_string());
130+
TuiEvent::ScanError(msg) => {
131+
app.is_scanning = false;
132+
app.status_message = Some(format!("Scan error: {}", msg));
133+
}
124134
}
125135
}
126-
TuiEvent::ScanError(msg) => {
127-
app.is_scanning = false;
128-
app.status_message = Some(format!("Scan error: {}", msg));
129-
}
136+
Err(_) => break,
130137
}
131138
}
132139

@@ -146,9 +153,7 @@ pub async fn run_tui(
146153
app.status_message = Some(format!("Invalid IP or CIDR Prefix: '{}'", target_str));
147154
} else {
148155
app.is_scanning = true;
149-
app.logs.clear();
150-
app.stats = app::ScanStats::default();
151-
app.stats.start_time = Some(std::time::Instant::now());
156+
app.clear_scan_data();
152157

153158
let db_path = app.db_path.clone();
154159
let db_key = app.db_key.clone();
@@ -195,9 +200,7 @@ pub async fn run_tui(
195200
events::UserAction::StartFullScan => {
196201
if !app.is_scanning {
197202
app.is_scanning = true;
198-
app.logs.clear();
199-
app.stats = app::ScanStats::default();
200-
app.stats.start_time = Some(std::time::Instant::now());
203+
app.clear_scan_data();
201204

202205
let db_path = app.db_path.clone();
203206
let db_key = app.db_key.clone();

src/tui/ui.rs

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,7 @@ fn render_results_viewer(frame: &mut Frame, app: &App, area: Rect) {
210210
.map(|h| Span::styled(*h, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)));
211211
let header = Row::new(header_cells).height(1).bottom_margin(1);
212212

213-
// Filter logs to ONLY show Vulnerable (Open) and Open/Protected findings
214-
let filtered_logs: Vec<_> = app
215-
.logs
216-
.iter()
217-
.filter(|log| log.status == PortStatus::Open || log.status == PortStatus::OpenProtected)
218-
.collect();
219-
220-
let total_findings = filtered_logs.len();
213+
let total_findings = app.filtered_log_indices.len();
221214

222215
// Dynamically calculate visible rows based on left component height
223216
let visible_height = (left_area.height as usize).saturating_sub(4);
@@ -237,31 +230,39 @@ fn render_results_viewer(frame: &mut Frame, app: &App, area: Rect) {
237230
ideal_start.min(max_start)
238231
};
239232

240-
let rows = filtered_logs.iter().enumerate().skip(start_idx).take(visible_height).map(|(idx, log)| {
241-
let is_selected = idx == selected_idx;
242-
let (status_str, status_style) = match &log.status {
243-
PortStatus::Open => ("VULNERABLE", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
244-
PortStatus::OpenProtected => ("Open/Protected", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
245-
_ => ("Other", Style::default().fg(Color::DarkGray)),
246-
};
247-
248-
let latency_str = log.response_time_ms.map(|ms| format!("{} ms", ms)).unwrap_or_else(|| "-".to_string());
249-
250-
let row_style = if is_selected {
251-
Style::default().bg(Color::DarkGray).fg(Color::White).add_modifier(Modifier::BOLD)
252-
} else {
253-
Style::default()
254-
};
255-
256-
Row::new(vec![
257-
Span::raw((idx + 1).to_string()),
258-
Span::raw(log.ip.to_string()),
259-
Span::raw(log.port.to_string()),
260-
Span::raw(log.service_name.clone()),
261-
Span::styled(status_str, status_style),
262-
Span::raw(latency_str),
263-
]).style(row_style)
264-
});
233+
let rows = app
234+
.filtered_log_indices
235+
.iter()
236+
.skip(start_idx)
237+
.take(visible_height)
238+
.enumerate()
239+
.map(|(rel_idx, &log_idx)| {
240+
let abs_idx = start_idx + rel_idx;
241+
let is_selected = abs_idx == selected_idx;
242+
let log = &app.logs[log_idx];
243+
let (status_str, status_style) = match &log.status {
244+
PortStatus::Open => ("VULNERABLE", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
245+
PortStatus::OpenProtected => ("Open/Protected", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
246+
_ => ("Other", Style::default().fg(Color::DarkGray)),
247+
};
248+
249+
let latency_str = log.response_time_ms.map(|ms| format!("{} ms", ms)).unwrap_or_else(|| "-".to_string());
250+
251+
let row_style = if is_selected {
252+
Style::default().bg(Color::DarkGray).fg(Color::White).add_modifier(Modifier::BOLD)
253+
} else {
254+
Style::default()
255+
};
256+
257+
Row::new(vec![
258+
Span::raw((abs_idx + 1).to_string()),
259+
Span::raw(log.ip.to_string()),
260+
Span::raw(log.port.to_string()),
261+
Span::raw(log.service_name.clone()),
262+
Span::styled(status_str, status_style),
263+
Span::raw(latency_str),
264+
]).style(row_style)
265+
});
265266

266267
let display_counter = if total_findings == 0 { 0 } else { selected_idx + 1 };
267268
let title_text = if total_findings == 0 {
@@ -302,8 +303,7 @@ fn render_results_viewer(frame: &mut Frame, app: &App, area: Rect) {
302303

303304
if !app.ports.is_empty() {
304305
for port in &app.ports {
305-
let vuln = app.logs.iter().filter(|l| l.port == port.port && l.status == PortStatus::Open).count();
306-
let prot = app.logs.iter().filter(|l| l.port == port.port && l.status == PortStatus::OpenProtected).count();
306+
let (vuln, prot) = app.port_stats.get(&port.port).copied().unwrap_or((0, 0));
307307
stats_list.push(PortStat {
308308
display_name: format!("{} ({}/{})", port.name, port.port, port.protocol),
309309
vulnerable: vuln,

0 commit comments

Comments
 (0)