From 2c6345dd1c19135c62aa0f3d73d73de3b11507e7 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Sat, 15 Aug 2026 03:41:55 +0100 Subject: [PATCH 1/2] Move blocking work off tokio workers and fix GUI interaction defects B-10, five sync-in-async call sites: - `event::poll(tick_rate)` parked a tokio worker for 100ms every loop iteration whether or not a key arrived. Poll and read move together to a blocking thread so the read cannot race another poller. - `NetworkManager::find_mac` shells out to `arp` on Windows and macOS. - `pcap_check_support` enumerates devices via a blocking syscall, twice per /pcap. Uses block_in_place because `ops` is a borrow, not 'static. - `get_stats` holds a mutex across a `Networks::refresh` syscall and was called from the draw path. Drawing is synchronous so it cannot yield; the sample moves to the event loop and the draw path reads cached numbers. - `detect_default_ipv4_addr` at startup delayed every path behind it. B-11: /config wrote settings on every arrow keypress -- a synchronous fs::write + fs::rename per key event, on the event-loop thread, so key repeat meant one full file rewrite per repeat tick. Cycling now marks the settings dirty and the single write happens in exit_config, which every close path (Esc, Done, toggle) already funnels through. Reset still writes immediately, being discrete and destructive. B-36: the spinner advanced an index from the draw path, coupling its speed to the frame rate and making rendering mutate state. It is now derived from wall time, so it spins at a constant rate and takes &self. GUI: M-1: History menu items keyed on their label, so two runs of the same command collided and React dropped one. M-2: keyboard column resize read `column.width` -- the static column definition, not the live width -- so every press recomputed from the same base and holding an arrow moved the border one step and stopped. M-3: NumberField clamped on every keystroke, so in a field with min 10 the "5" of "50" became "10" before the "0" arrived, and the field could not be cleared. Clamping moves to blur/Enter/steppers. M-5: the menu strip had no menubar semantics. M-7: the table and detail pane disagreed on latency -- a closed port read '' in one and 'refused' in the other, and both were used inside the detail pane. One function now. C-12: DetailList keys collided for two identical TXT records. C-16: the progress line counted `1-1024` as one port and said "1 ports". C-32: duplicate App.css import; the non-null root assertion now throws a real message. 94 GUI tests (up from 88) and 134 Rust tests. clippy clean with and without --features pcap. Verified in the running GUI: menubar exposes 7 menuitems, and the number field keeps partial input and stays clearable. NumberField's commit paths are covered by component tests rather than the browser, because a focus trap in the app prevented a real blur through the automation harness. --- apps/netscli-cli/src/main.rs | 8 +- apps/netscli-cli/src/tui/events/pcap.rs | 7 +- apps/netscli-cli/src/tui/events/scan.rs | 9 +- apps/netscli-cli/src/tui/runtime.rs | 24 +++- apps/netscli-cli/src/tui/state/config_ui.rs | 25 +++- apps/netscli-cli/src/tui/state/mod.rs | 5 +- apps/netscli-cli/src/tui/state/render.rs | 1 + .../netscli-cli/src/tui/state/render/input.rs | 77 +------------ .../netscli-cli/src/tui/state/render/stats.rs | 109 ++++++++++++++++++ .../src/components/results/DetailList.tsx | 6 +- .../results/OperationProgress.test.ts | 41 +++++++ .../components/results/OperationProgress.tsx | 22 +++- .../results/resultTableInteractions.ts | 23 ++-- .../src/components/shell/MenuBar.tsx | 11 +- .../src/components/tools/NumberField.test.tsx | 76 ++++++++++++ .../src/components/tools/NumberField.tsx | 14 ++- apps/netscli-gui/src/main.tsx | 9 +- .../src/tools/presentation.test.ts | 20 +++- apps/netscli-gui/src/tools/presentation.ts | 2 + .../src/tools/presentation/portDetails.ts | 18 +-- .../src/tools/presentation/ports.ts | 18 ++- 21 files changed, 404 insertions(+), 121 deletions(-) create mode 100644 apps/netscli-cli/src/tui/state/render/stats.rs create mode 100644 apps/netscli-gui/src/components/results/OperationProgress.test.ts create mode 100644 apps/netscli-gui/src/components/tools/NumberField.test.tsx diff --git a/apps/netscli-cli/src/main.rs b/apps/netscli-cli/src/main.rs index a9f3562a..d3194a2e 100644 --- a/apps/netscli-cli/src/main.rs +++ b/apps/netscli-cli/src/main.rs @@ -31,7 +31,13 @@ async fn main() -> Result<()> { .unwrap_or_else(|| tui_settings::load_settings().max_concurrent_probes), ..Default::default() }); - let local_addr = netscli_core::detect_default_ipv4_addr().map(|ip| ip.to_string()); + // Route enumeration is a blocking syscall; this runs inside the async + // main, so it delayed every startup path behind it (B-10). + let local_addr = tokio::task::spawn_blocking(netscli_core::detect_default_ipv4_addr) + .await + .ok() + .flatten() + .map(|ip| ip.to_string()); if let Some(command) = &cli.command { cli_dispatch::run_command( diff --git a/apps/netscli-cli/src/tui/events/pcap.rs b/apps/netscli-cli/src/tui/events/pcap.rs index 7ba09561..9bd9d6c1 100644 --- a/apps/netscli-cli/src/tui/events/pcap.rs +++ b/apps/netscli-cli/src/tui/events/pcap.rs @@ -122,7 +122,10 @@ pub(super) async fn handle( } if check || interface.is_none() { - match ops.pcap_check_support() { + // Device enumeration is a blocking syscall on every platform, and + // this runs on a tokio worker (B-10). `block_in_place` rather than + // `spawn_blocking` because `ops` is a borrow, not `'static`. + match tokio::task::block_in_place(|| ops.pcap_check_support()) { Ok(devs) => { out.extend(Formatter::format_pcap_interfaces(&devs)); if !check && interface.is_none() { @@ -152,7 +155,7 @@ pub(super) async fn handle( } let iface = interface.unwrap_or_else(|| "unknown".to_string()); - match ops.pcap_check_support() { + match tokio::task::block_in_place(|| ops.pcap_check_support()) { // Deliberately no name check here (B-12). // // This used to require exact string equality against a device name, diff --git a/apps/netscli-cli/src/tui/events/scan.rs b/apps/netscli-cli/src/tui/events/scan.rs index 5efb4921..57c11225 100644 --- a/apps/netscli-cli/src/tui/events/scan.rs +++ b/apps/netscli-cli/src/tui/events/scan.rs @@ -97,7 +97,14 @@ pub(super) async fn handle_scan( commands::db_add_scan_history_safe(db, "scan", 0, &res).await; } - let arp = netscli_core::NetworkManager::find_mac(&ip); + // `find_mac` shells out to `arp` on Windows and macOS, so + // calling it inline blocked a tokio worker for the lifetime + // of a subprocess (B-10). + let arp = tokio::task::spawn_blocking(move || { + netscli_core::NetworkManager::find_mac(&ip) + }) + .await + .unwrap_or(None); let mac = arp.as_ref().map(|e| e.mac.to_string()); let vendor = arp.and_then(|e| e.vendor); let hostname = netscli_core::dns::reverse_lookup_best_effort_timeout( diff --git a/apps/netscli-cli/src/tui/runtime.rs b/apps/netscli-cli/src/tui/runtime.rs index d31d3e9e..57a0d97a 100644 --- a/apps/netscli-cli/src/tui/runtime.rs +++ b/apps/netscli-cli/src/tui/runtime.rs @@ -28,6 +28,11 @@ pub async fn run_tui(concurrency: Option) -> Result<()> { input.refresh_exit_confirmation(&mut app); tasks.refresh_running_detail(&mut app); + // Sample traffic here rather than from the draw path: `get_stats` + // holds a mutex across a syscall, and drawing is synchronous so it + // cannot yield (B-10). `block_in_place` keeps the borrow. + tokio::task::block_in_place(|| app.refresh_traffic_stats()); + app.draw(&mut terminal)?; if app.running { app.suggestions.clear(); @@ -37,11 +42,22 @@ pub async fn run_tui(concurrency: Option) -> Result<()> { tasks.finish_ready_task(&mut app).await; - if !event::poll(tick_rate)? { - continue; - } + // `event::poll` parks the calling thread for up to `tick_rate`, and + // this loop runs on a tokio worker — so every iteration blocked a + // worker for 100ms whether or not a key arrived (B-10). Both the poll + // and the read move to a blocking thread; they stay together so the + // read cannot race another poller. + let polled = tokio::task::spawn_blocking(move || -> std::io::Result> { + if event::poll(tick_rate)? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } + }) + .await + .map_err(|e| std::io::Error::other(e.to_string()))??; - let Event::Key(key) = event::read()? else { + let Some(Event::Key(key)) = polled else { continue; }; diff --git a/apps/netscli-cli/src/tui/state/config_ui.rs b/apps/netscli-cli/src/tui/state/config_ui.rs index e013997c..5c9f5669 100644 --- a/apps/netscli-cli/src/tui/state/config_ui.rs +++ b/apps/netscli-cli/src/tui/state/config_ui.rs @@ -15,10 +15,25 @@ impl<'a> TuiApp<'a> { } pub fn exit_config(&mut self) { + // Every close path funnels through here (Esc, Done, /config toggle), + // so this is where the single write belongs. Cycling a value used to + // do a synchronous fs::write + fs::rename per key event, on the event + // loop thread, which under key-repeat meant one full file rewrite per + // repeat tick (B-11). + self.flush_settings_if_dirty(); self.ui_mode = UiMode::Normal; self.scroll_to_bottom(); } + /// Persist settings only if something actually changed. + fn flush_settings_if_dirty(&mut self) -> Option { + if !self.settings_dirty { + return None; + } + self.settings_dirty = false; + self.config_save_settings() + } + pub fn config_next(&mut self) { let Some(state) = self.config_state_mut() else { return; @@ -111,9 +126,10 @@ impl<'a> TuiApp<'a> { ConfigItemKind::Reset | ConfigItemKind::Done => {} } - let msg = self.config_save_settings(); + // Deferred to exit_config; see B-11 there. + self.settings_dirty = true; if let Some(state) = self.config_state_mut() { - state.message = msg; + state.message = None; } } @@ -140,7 +156,10 @@ impl<'a> TuiApp<'a> { _ => false, }; - let msg = self.config_save_settings(); + // Reset is a discrete, destructive action, so it is written through + // immediately rather than waiting for the panel to close. + self.settings_dirty = true; + let msg = self.flush_settings_if_dirty(); if let Some(state) = self.config_state_mut() { state.message = msg; } diff --git a/apps/netscli-cli/src/tui/state/mod.rs b/apps/netscli-cli/src/tui/state/mod.rs index df4e66b6..5065879e 100644 --- a/apps/netscli-cli/src/tui/state/mod.rs +++ b/apps/netscli-cli/src/tui/state/mod.rs @@ -42,7 +42,6 @@ pub struct TuiApp<'a> { pub confirm_exit: bool, pub running: bool, pub running_detail: Option, - pub spinner_idx: usize, pub settings: TuiSettings, pub concurrency_override: Option, pub hostname: String, @@ -56,6 +55,8 @@ pub struct TuiApp<'a> { stats_download_active: bool, input_scroll_x: usize, ui_mode: UiMode, + /// Set when /config changes a value; cleared when it is written to disk. + settings_dirty: bool, } impl<'a> TuiApp<'a> { @@ -87,7 +88,6 @@ impl<'a> TuiApp<'a> { confirm_exit: false, running: false, running_detail: None, - spinner_idx: 0, settings: TuiSettings::default(), concurrency_override: None, hostname, @@ -101,6 +101,7 @@ impl<'a> TuiApp<'a> { stats_download_active: false, input_scroll_x: 0, ui_mode: UiMode::Normal, + settings_dirty: false, } } diff --git a/apps/netscli-cli/src/tui/state/render.rs b/apps/netscli-cli/src/tui/state/render.rs index da9f0593..d442de2a 100644 --- a/apps/netscli-cli/src/tui/state/render.rs +++ b/apps/netscli-cli/src/tui/state/render.rs @@ -13,6 +13,7 @@ mod content; mod input; mod message; mod scrollbar; +mod stats; impl<'a> TuiApp<'a> { pub fn draw(&mut self, terminal: &mut Terminal) -> Result<(), B::Error> { diff --git a/apps/netscli-cli/src/tui/state/render/input.rs b/apps/netscli-cli/src/tui/state/render/input.rs index 65d30dc5..7d2648fd 100644 --- a/apps/netscli-cli/src/tui/state/render/input.rs +++ b/apps/netscli-cli/src/tui/state/render/input.rs @@ -6,10 +6,9 @@ use super::super::super::widgets::{ value_style, }; use super::super::{TuiApp, INPUT_PLACEHOLDER}; -use crate::tui_settings::StatsUnit; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, + style::Style, text::{Line, Span}, widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}, Frame, @@ -258,78 +257,4 @@ impl<'a> TuiApp<'a> { inner, ); } - - fn render_stats_lines(&mut self) -> (Line<'static>, Line<'static>) { - let stats = self.monitor.get_stats(); - if stats.available { - self.stats_upload_mbps = stats.upload_mbps; - self.stats_download_mbps = stats.download_mbps; - self.stats_upload_active = stats.upload_active; - self.stats_download_active = stats.download_active; - } else { - self.stats_upload_mbps = 0.0; - self.stats_download_mbps = 0.0; - self.stats_upload_active = false; - self.stats_download_active = false; - } - - let dot = " · "; - let unit: StatsUnit = self.settings.stats_unit; - let up = format!( - "{:.2}", - unit.scale_from_mbps(self.stats_upload_mbps).min(999.99) - ); - let down = format!( - "{:.2}", - unit.scale_from_mbps(self.stats_download_mbps).min(999.99) - ); - let unit_suffix = format!(" {}", unit.suffix()); - - let left = Line::from(vec![ - Span::styled("host ", label_style()), - Span::styled(self.hostname.clone(), value_style()), - Span::styled(dot, label_style()), - Span::styled("ip ", label_style()), - Span::styled( - self.context_address - .clone() - .unwrap_or_else(|| "n/a".to_string()), - value_style(), - ), - ]); - - let up_arrow_style = if self.stats_upload_active { - Style::default().fg(Color::Cyan) - } else { - label_style() - }; - let down_arrow_style = if self.stats_download_active { - Style::default().fg(Color::Cyan) - } else { - label_style() - }; - let number_style = value_style(); - - let right_spans = vec![ - Span::styled("↑ ", up_arrow_style), - Span::styled(up, number_style), - Span::styled(unit_suffix.clone(), label_style()), - Span::styled(dot, label_style()), - Span::styled("↓ ", down_arrow_style), - Span::styled(down, number_style), - Span::styled(unit_suffix, label_style()), - ]; - - (left, Line::from(right_spans)) - } - - pub(super) fn spinner_frame(&mut self) -> &'static str { - if !self.running { - return " "; - } - let frames = ["-", "\\", "|", "/"]; - let ch = frames[self.spinner_idx % frames.len()]; - self.spinner_idx = (self.spinner_idx + 1) % frames.len(); - ch - } } diff --git a/apps/netscli-cli/src/tui/state/render/stats.rs b/apps/netscli-cli/src/tui/state/render/stats.rs new file mode 100644 index 00000000..271ee83e --- /dev/null +++ b/apps/netscli-cli/src/tui/state/render/stats.rs @@ -0,0 +1,109 @@ +//! Traffic-stats sampling and the stats line the input box renders under it. +//! +//! Split from `render/input.rs` to keep it under the maintainability cap; the +//! gate's own note named splitting the mode renderers as the next step and +//! this is the self-contained half. + +use super::super::super::widgets::{label_style, value_style}; +use super::super::TuiApp; +use crate::tui_settings::StatsUnit; +use ratatui::{ + style::{Color, Style}, + text::{Line, Span}, +}; + +impl TuiApp<'_> { + /// Sample the traffic monitor and cache the result. + /// + /// This used to happen inside `render_stats_lines`, i.e. from the draw + /// path — and `get_stats` holds a mutex across a `Networks::refresh` + /// syscall (B-10). Drawing is synchronous, so the fix is not + /// `spawn_blocking` but moving the sample to the async event loop, which + /// can yield around it. The draw path now only reads cached numbers. + pub(in crate::tui) fn refresh_traffic_stats(&mut self) { + let stats = self.monitor.get_stats(); + if stats.available { + self.stats_upload_mbps = stats.upload_mbps; + self.stats_download_mbps = stats.download_mbps; + self.stats_upload_active = stats.upload_active; + self.stats_download_active = stats.download_active; + } else { + self.stats_upload_mbps = 0.0; + self.stats_download_mbps = 0.0; + self.stats_upload_active = false; + self.stats_download_active = false; + } + } + + pub(super) fn render_stats_lines(&mut self) -> (Line<'static>, Line<'static>) { + let dot = " · "; + let unit: StatsUnit = self.settings.stats_unit; + let up = format!( + "{:.2}", + unit.scale_from_mbps(self.stats_upload_mbps).min(999.99) + ); + let down = format!( + "{:.2}", + unit.scale_from_mbps(self.stats_download_mbps).min(999.99) + ); + let unit_suffix = format!(" {}", unit.suffix()); + + let left = Line::from(vec![ + Span::styled("host ", label_style()), + Span::styled(self.hostname.clone(), value_style()), + Span::styled(dot, label_style()), + Span::styled("ip ", label_style()), + Span::styled( + self.context_address + .clone() + .unwrap_or_else(|| "n/a".to_string()), + value_style(), + ), + ]); + + let up_arrow_style = if self.stats_upload_active { + Style::default().fg(Color::Cyan) + } else { + label_style() + }; + let down_arrow_style = if self.stats_download_active { + Style::default().fg(Color::Cyan) + } else { + label_style() + }; + let number_style = value_style(); + + let right_spans = vec![ + Span::styled("↑ ", up_arrow_style), + Span::styled(up, number_style), + Span::styled(unit_suffix.clone(), label_style()), + Span::styled(dot, label_style()), + Span::styled("↓ ", down_arrow_style), + Span::styled(down, number_style), + Span::styled(unit_suffix, label_style()), + ]; + + (left, Line::from(right_spans)) + } + + /// Current spinner glyph, derived from wall time. + /// + /// This used to advance an index every time it was called — from the draw + /// path (B-36). That coupled the spinner's speed to the frame rate, so it + /// span faster on a busy redraw and stalled when nothing else forced a + /// frame, and it made rendering mutate state. Wall time gives a constant + /// rate regardless of how often the screen is drawn, and lets this take + /// `&self`. + pub(super) fn spinner_frame(&self) -> &'static str { + if !self.running { + return " "; + } + const FRAMES: [&str; 4] = ["-", "\\", "|", "/"]; + const FRAME_MS: u128 = 120; + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + FRAMES[((ms / FRAME_MS) as usize) % FRAMES.len()] + } +} diff --git a/apps/netscli-gui/src/components/results/DetailList.tsx b/apps/netscli-gui/src/components/results/DetailList.tsx index e60af84b..18729a4d 100644 --- a/apps/netscli-gui/src/components/results/DetailList.tsx +++ b/apps/netscli-gui/src/components/results/DetailList.tsx @@ -1,8 +1,10 @@ export function DetailList({ lines }: { lines: { label: string; value: string; muted?: boolean }[] }) { return (
- {lines.map((line) => ( -
+ {lines.map((line, index) => ( +
{line.label} {line.value}
diff --git a/apps/netscli-gui/src/components/results/OperationProgress.test.ts b/apps/netscli-gui/src/components/results/OperationProgress.test.ts new file mode 100644 index 00000000..e73ba5a2 --- /dev/null +++ b/apps/netscli-gui/src/components/results/OperationProgress.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { countList } from './OperationProgress'; + +// C-16: the progress line reported "1 ports" for a full 1-1024 sweep, because +// the count split on `,` only and never expanded a range. +describe('port count in the progress line', () => { + it('counts comma-separated singles', () => { + expect(countList('22,80,443')).toBe(3); + }); + + it('expands a range instead of counting it as one', () => { + expect(countList('1-1024')).toBe(1024); + expect(countList('80-80')).toBe(1); + }); + + it('handles ranges mixed with singles', () => { + expect(countList('22,80-89,443')).toBe(12); + }); + + it('tolerates whitespace around a range', () => { + expect(countList('1 - 3')).toBe(3); + }); + + it('ignores a reversed range rather than going negative', () => { + // A negative contribution would make the total smaller than the singles + // beside it, which reads as nonsense in the UI. + expect(countList('100-1')).toBe(0); + expect(countList('22,100-1,443')).toBe(2); + }); + + it('returns 0 for empty or undefined input', () => { + expect(countList(undefined)).toBe(0); + expect(countList('')).toBe(0); + expect(countList(' ')).toBe(0); + }); + + it('counts a non-numeric token as one entry rather than dropping it', () => { + expect(countList('http')).toBe(1); + }); +}); diff --git a/apps/netscli-gui/src/components/results/OperationProgress.tsx b/apps/netscli-gui/src/components/results/OperationProgress.tsx index fe9410a9..06ea7fb9 100644 --- a/apps/netscli-gui/src/components/results/OperationProgress.tsx +++ b/apps/netscli-gui/src/components/results/OperationProgress.tsx @@ -101,7 +101,25 @@ function operationDetail(tab: WorkspaceTab): string { } } -function countList(value: string | undefined): number { +/** + * Count the ports a spec expands to. + * + * Splitting on `,` alone counted `1-1024` as a single port, so the progress + * line read "1 ports" for a full sweep (C-16). Ranges now expand, and a + * reversed or malformed range contributes nothing rather than a negative. + */ +export function countList(value: string | undefined): number { if (!value) return 0; - return value.split(',').map((part) => part.trim()).filter(Boolean).length; + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .reduce((total, part) => { + const range = part.match(/^(\d+)\s*-\s*(\d+)$/); + if (!range) return total + 1; + const start = Number(range[1]); + const end = Number(range[2]); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return total; + return total + (end - start + 1); + }, 0); } diff --git a/apps/netscli-gui/src/components/results/resultTableInteractions.ts b/apps/netscli-gui/src/components/results/resultTableInteractions.ts index f21ad921..dd2f4726 100644 --- a/apps/netscli-gui/src/components/results/resultTableInteractions.ts +++ b/apps/netscli-gui/src/components/results/resultTableInteractions.ts @@ -52,27 +52,36 @@ export function handleColumnResizeKeyDown( setColumnWidths: Dispatch>>, ) { const step = event.shiftKey ? 48 : 16; - let nextWidth: number | null = null; - + const MIN = 72; + const MAX = 640; + + // Each case returns the next width given the *current* one. It used to read + // `column.width` -- the static column definition, not the live resized + // value -- so every keypress recomputed from the same base and holding an + // arrow key moved the border exactly one step and then stopped (M-2). + let nextFrom: ((current: number) => number) | null = null; switch (event.key) { case 'ArrowLeft': - nextWidth = Math.max(72, (column.width ?? 120) - step); + nextFrom = (current) => Math.max(MIN, current - step); break; case 'ArrowRight': - nextWidth = Math.min(640, (column.width ?? 120) + step); + nextFrom = (current) => Math.min(MAX, current + step); break; case 'Home': - nextWidth = 72; + nextFrom = () => MIN; break; case 'End': - nextWidth = 640; + nextFrom = () => MAX; break; default: return; } event.preventDefault(); - setColumnWidths((prev) => ({ ...prev, [column.key]: nextWidth })); + setColumnWidths((prev) => { + const current = prev[column.key] ?? column.width ?? 120; + return { ...prev, [column.key]: nextFrom(current) }; + }); } export function startColumnResize( diff --git a/apps/netscli-gui/src/components/shell/MenuBar.tsx b/apps/netscli-gui/src/components/shell/MenuBar.tsx index 1c0f5098..82bf96e1 100644 --- a/apps/netscli-gui/src/components/shell/MenuBar.tsx +++ b/apps/netscli-gui/src/components/shell/MenuBar.tsx @@ -301,14 +301,15 @@ export function MenuBar({ ]; return ( -
+
{groups.map((group) => ( -
+