netspd 0.1.7

A beautiful, modern, minimalistic network speed testing terminal application
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! The asynchronous runtime loop wiring terminal, engine and renderer.

use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyEventKind};
use futures_util::StreamExt;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::engine::models::Server;
use crate::engine::{Engine, EngineConfig, EngineEvent};
use crate::tui::renderer::Renderer;
use crate::tui::terminal::Tui;

use super::action::Command;
use super::controller;
use super::event::AppEvent;
use super::history;
use super::state::{AppState, Screen};

/// Minimum time the splash screen stays visible, so startup never flashes.
const MIN_SPLASH: Duration = Duration::from_millis(1200);

/// Capacity of the engine event channel; progress events are small and
/// frequent, so a modest buffer avoids backpressure on the engine.
const ENGINE_CHANNEL_CAPACITY: usize = 64;

/// How many alternative servers are tried automatically after a failure
/// before showing the error screen.
const MAX_FAILOVER: usize = 2;

/// Number of past results loaded for the trends screen.
const TRENDS_LIMIT: usize = 120;

/// The application: owns the engine, the state and the renderer, and runs
/// the event loop until the user quits.
pub struct App {
    engine: Arc<Engine>,
    engine_config: EngineConfig,
    state: AppState,
    renderer: Renderer,
    tick_rate: Duration,
    engine_rx: Option<mpsc::Receiver<EngineEvent>>,
    servers_rx: Option<mpsc::Receiver<Result<Vec<Server>, String>>>,
    info_rx: Option<mpsc::Receiver<String>>,
    cancel: CancellationToken,
    failover_attempts: usize,
    prom_textfile: Option<std::path::PathBuf>,
    /// The screen rendered on the previous draw call; used to detect
    /// transitions that need a full terminal clear.
    last_drawn_screen: Option<Screen>,
}

impl App {
    /// Assembles the application from its parts.
    ///
    /// `engine_config` is kept so the engine can be rebuilt when the
    /// user switches providers at runtime.
    #[must_use]
    pub fn new(
        engine: Engine,
        engine_config: EngineConfig,
        state: AppState,
        renderer: Renderer,
        tick_rate: Duration,
    ) -> Self {
        Self {
            engine: Arc::new(engine),
            engine_config,
            state,
            renderer,
            tick_rate,
            engine_rx: None,
            servers_rx: None,
            info_rx: None,
            cancel: CancellationToken::new(),
            failover_attempts: 0,
            prom_textfile: None,
            last_drawn_screen: None,
        }
    }

    /// Also write Prometheus metrics after each completed run.
    #[must_use]
    pub fn with_prom_textfile(mut self, path: Option<std::path::PathBuf>) -> Self {
        self.prom_textfile = path;
        self
    }

    /// Runs the event loop until quit, drawing at most one frame per tick.
    pub async fn run(mut self, tui: &mut Tui) -> Result<()> {
        self.servers_rx = Some(self.spawn_discovery());
        let mut input = EventStream::new();
        let mut ticker = tokio::time::interval(self.tick_rate);
        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        let mut last_frame = Instant::now();

        self.draw(tui, 0.0)?;

        loop {
            let event = tokio::select! {
                maybe = input.next() => match maybe {
                    Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
                        AppEvent::Key(key)
                    }
                    Some(Ok(Event::Mouse(mouse))) => AppEvent::Mouse(mouse.kind),
                    Some(Ok(Event::Resize(_, _))) => AppEvent::Resize,
                    Some(Ok(_)) => continue,
                    // Terminal input errored or closed; nothing sensible
                    // remains but to exit cleanly.
                    Some(Err(_)) | None => break,
                },
                _ = ticker.tick() => AppEvent::Tick,
                maybe = recv_or_pending(&mut self.engine_rx) => match maybe {
                    Some(engine_event) => AppEvent::Engine(engine_event),
                    None => {
                        self.engine_rx = None;
                        continue;
                    }
                },
                maybe = recv_or_pending(&mut self.servers_rx) => match maybe {
                    Some(result) => AppEvent::ServersLoaded(result),
                    None => {
                        self.servers_rx = None;
                        continue;
                    }
                },
                maybe = recv_or_pending(&mut self.info_rx) => match maybe {
                    Some(info) => AppEvent::ClientInfo(info),
                    None => {
                        self.info_rx = None;
                        continue;
                    }
                },
            };

            match event {
                AppEvent::Key(key) => {
                    let Some(action) = controller::map_key(self.state.screen, key) else {
                        continue;
                    };
                    let command = controller::handle(&mut self.state, action);
                    if self.execute(command) {
                        break;
                    }
                }
                AppEvent::Mouse(kind) => {
                    let Some(action) = controller::map_mouse(self.state.screen, kind) else {
                        continue;
                    };
                    let command = controller::handle(&mut self.state, action);
                    if self.execute(command) {
                        break;
                    }
                }
                AppEvent::Resize => self.state.request_redraw(),
                AppEvent::Engine(engine_event) => {
                    if matches!(engine_event, EngineEvent::Failed { .. }) && self.try_failover() {
                        continue;
                    }
                    if let EngineEvent::Finished { report } = &engine_event {
                        self.failover_attempts = 0;
                        // Best-effort persistence; the UI never blocks on it.
                        history::record_report(report);
                        if let Some(path) = &self.prom_textfile {
                            let _ =
                                super::prom::write_textfile(path, report, self.state.provider_name);
                        }
                        if self.state.settings.notify {
                            let report_clone = report.clone();
                            let provider = self.state.provider_name;
                            tokio::task::spawn_blocking(move || {
                                super::notify::fire(&report_clone, provider);
                            });
                        }
                    }
                    self.state.apply_engine_event(engine_event);
                }
                AppEvent::ServersLoaded(result) => {
                    self.state.apply_servers(result);
                    if self.info_rx.is_none() {
                        self.info_rx = self.spawn_info_fetch();
                    }
                }
                AppEvent::ClientInfo(info) => {
                    self.state.client_info = Some(info);
                    self.state.request_redraw();
                }
                AppEvent::Tick => {
                    self.state.on_tick();
                    self.maybe_leave_splash();
                    self.maybe_repeat();
                    if self.state.take_redraw() {
                        let dt = last_frame.elapsed().as_secs_f64();
                        last_frame = Instant::now();
                        self.draw(tui, dt)?;
                    }
                }
            }
        }

        self.cancel.cancel();
        Ok(())
    }

    /// Starts server discovery in the background.
    fn spawn_discovery(&self) -> mpsc::Receiver<Result<Vec<Server>, String>> {
        let (tx, rx) = mpsc::channel(1);
        let engine = Arc::clone(&self.engine);
        tokio::spawn(async move {
            let result = engine.load_servers().await.map_err(|err| err.to_string());
            let _ = tx.send(result).await;
        });
        rx
    }

    /// Fetches the client's IP/ISP through the selected server, once
    /// discovery has produced one.
    fn spawn_info_fetch(&self) -> Option<mpsc::Receiver<String>> {
        let server = self.state.current_server().cloned()?;
        let (tx, rx) = mpsc::channel(1);
        let engine = Arc::clone(&self.engine);
        tokio::spawn(async move {
            if let Some(info) = engine.client_info(&server).await {
                let _ = tx.send(info).await;
            }
        });
        Some(rx)
    }

    /// Automatically starts the first test once the splash has been shown
    /// long enough and servers are available.
    fn maybe_leave_splash(&mut self) {
        let ready = self.state.screen == Screen::Splash
            && !self.state.servers_loading
            && !self.state.testing
            && !self.state.servers.is_empty()
            && self.state.splash_since.elapsed() >= MIN_SPLASH;
        if ready {
            self.start_test();
        }
    }

    /// Performs a controller command; returns `true` when the loop
    /// should exit.
    fn execute(&mut self, command: Command) -> bool {
        match command {
            Command::Quit => return true,
            Command::StartTest => {
                self.failover_attempts = 0;
                self.start_test();
            }
            Command::LoadTrends => {
                self.state.trends = history::load_recent(TRENDS_LIMIT);
            }
            Command::Share => self.share_result(),
            Command::ReloadProvider => self.reload_provider(),
            Command::SaveConfig => match crate::config::save(&self.state.settings) {
                Ok(path) => self
                    .state
                    .set_notice(format!("saved to {}", path.display())),
                Err(err) => self.state.set_notice(err.to_string()),
            },
            Command::None => {}
        }
        false
    }

    /// Rebuilds the engine for the provider selected on the settings
    /// screen, cancels any running test and starts fresh discovery.
    fn reload_provider(&mut self) {
        let kind = self.state.settings.provider;
        let provider =
            match crate::engine::providers::create(kind, self.state.settings.custom_servers()) {
                Ok(provider) => provider,
                Err(err) => {
                    self.state.set_notice(err.to_string());
                    return;
                }
            };
        let engine = match Engine::new(provider, self.engine_config) {
            Ok(engine) => engine,
            Err(err) => {
                self.state.set_notice(err.to_string());
                return;
            }
        };

        // Stop anything in flight against the old provider.
        self.cancel.cancel();
        self.cancel = CancellationToken::new();
        self.engine_rx = None;
        self.failover_attempts = 0;

        self.engine = Arc::new(engine);
        self.state.provider_name = self.engine.provider_name();
        self.state.reset_test();
        self.state.testing = false;
        self.state.servers.clear();
        self.state.server_index = 0;
        self.state.server_cursor = 0;
        self.state.client_info = None;
        self.state.servers_loading = true;
        self.info_rx = None;
        self.servers_rx = Some(self.spawn_discovery());
        self.state
            .set_notice(format!("{} — discovering servers…", kind.label()));
    }

    /// Copies the last result to the clipboard and shows a confirmation.
    fn share_result(&mut self) {
        let Some(report) = &self.state.report else {
            return;
        };
        let text = super::share::share_text(report, self.state.provider_name);
        match super::share::copy_to_clipboard(&text) {
            Ok(()) => self.state.set_notice("✓ result copied to clipboard"),
            Err(message) => self.state.set_notice(message),
        }
    }

    /// Auto-restarts the test once the configured repeat interval has
    /// elapsed since the last completion. Only fires from the results
    /// screen, so a user browsing overlays is never yanked away.
    fn maybe_repeat(&mut self) {
        let due = self.state.screen == Screen::Results
            && !self.state.testing
            && self
                .state
                .repeat_remaining()
                .is_some_and(|remaining| remaining.is_zero());
        if due {
            self.failover_attempts = 0;
            self.start_test();
        }
    }

    /// Moves to the next-nearest server after a failure and restarts,
    /// up to [`MAX_FAILOVER`] times. Returns whether a retry started.
    ///
    /// Skipped when the user pinned a server with `--server`: an explicit
    /// choice should fail loudly, not wander.
    fn try_failover(&mut self) -> bool {
        let next = self.state.server_index + 1;
        let possible = self.state.preferred_server.is_none()
            && self.failover_attempts < MAX_FAILOVER
            && next < self.state.servers.len();
        if !possible {
            return false;
        }
        self.failover_attempts += 1;
        self.state.server_index = next;
        self.state.server_cursor = next;
        self.start_test();
        true
    }

    /// Cancels any running test and launches a new one against the
    /// selected server.
    fn start_test(&mut self) {
        let Some(server) = self.state.current_server().cloned() else {
            return;
        };
        self.cancel.cancel();
        self.cancel = CancellationToken::new();
        self.state.begin_test();

        let (tx, rx) = mpsc::channel(ENGINE_CHANNEL_CAPACITY);
        self.engine_rx = Some(rx);
        let engine = Arc::clone(&self.engine);
        let cancel = self.cancel.clone();
        tokio::spawn(async move {
            // Failures already reach the UI as EngineEvent::Failed.
            let _ = engine.run_test(&server, tx, cancel).await;
        });
    }

    /// Renders one frame.
    fn draw(&mut self, tui: &mut Tui, dt: f64) -> Result<()> {
        let current = self.state.screen;
        // After a test, the dial canvas and progress bar push many escape
        // sequences per frame. Dropped sequences leave the terminal cursor
        // position out of sync with ratatui's model, causing characters to
        // land in wrong cells on the results screen. A full clear fixes it.
        //
        // Only clear on the transition to a settled full screen (Results,
        // Error, Splash). Opening an overlay mid-test also moves `screen`
        // away from Testing, but the test is still running and the overlay
        // draws over a live Testing background — clearing there would blank-
        // flash for no benefit.
        if self.last_drawn_screen == Some(Screen::Testing)
            && current != Screen::Testing
            && !current.is_overlay()
        {
            tui.clear()?;
        }
        self.last_drawn_screen = Some(current);
        let renderer = &mut self.renderer;
        let state = &self.state;
        tui.draw(|frame| renderer.render(frame, state, dt))?;
        Ok(())
    }
}

/// Receives from an optional channel, parking forever when absent so it
/// can sit inside `tokio::select!` without busy-looping.
async fn recv_or_pending<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
    match rx {
        Some(receiver) => receiver.recv().await,
        None => std::future::pending().await,
    }
}