Skip to main content

purple_ssh/app/
tunnel_state.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::Ordering;
3use std::sync::mpsc::{Receiver, Sender};
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use crate::app::TunnelFormBaseline;
8use crate::app::forms::TunnelForm;
9use crate::tunnel::{ActiveTunnel, TunnelRule};
10
11/// How long an exited tunnel waits for its parser to reach the end of
12/// stderr. A `ProxyCommand` that outlives ssh can hold the pipe open, so the
13/// wait is bounded.
14const EXIT_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
15
16/// A tunnel whose ssh ended, as `TunnelState::poll` reports it.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TunnelExit {
19    pub alias: String,
20    /// Toast text for the exit.
21    pub message: String,
22    pub is_error: bool,
23    /// ssh gave up on authentication before the tunnel came up.
24    pub auth_refused: bool,
25    /// The target let ssh in before it exited.
26    pub authenticated: bool,
27}
28
29/// Sort order for the tunnels overview screen. Cycled with `s`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum TunnelSortMode {
32    /// Recently-used hosts first (uses `app.history.last_connected`). Active
33    /// tunnels rank by `started_at` so they always sit above idle ones.
34    #[default]
35    MostRecent,
36    /// Alphabetical by host alias, ascending.
37    AlphaHostname,
38}
39
40impl TunnelSortMode {
41    pub fn next(self) -> Self {
42        match self {
43            TunnelSortMode::MostRecent => TunnelSortMode::AlphaHostname,
44            TunnelSortMode::AlphaHostname => TunnelSortMode::MostRecent,
45        }
46    }
47
48    pub fn label(self) -> &'static str {
49        match self {
50            TunnelSortMode::MostRecent => "most recent",
51            TunnelSortMode::AlphaHostname => "A-Z hostname",
52        }
53    }
54}
55use crate::tunnel_live::{
56    ChannelEventKind, ClientPeer, LsofMessage, LsofPollerHandle, PEER_VIZ_BUCKETS, ParserMessage,
57    PortConflict, TunnelLiveSnapshot,
58};
59
60/// Tunnel-owned state grouped off the `App` god-struct. Contains the rule
61/// list, the edit form, the live child-process map, form baseline for the
62/// dirty check, the pending delete index and the cached per-host summary
63/// strings. Pure state container; behaviour lives on `App` or on dedicated
64/// methods here.
65pub struct TunnelState {
66    pub(in crate::app) list: Vec<TunnelRule>,
67    pub(in crate::app) form: TunnelForm,
68    pub(in crate::app) active: HashMap<String, ActiveTunnel>,
69    pub(in crate::app) form_baseline: Option<TunnelFormBaseline>,
70    pub(in crate::app) pending_delete: Option<usize>,
71    pub(in crate::app) summaries_cache: HashMap<String, String>,
72    /// Sort mode for the tunnels overview screen. Cycled by `s`.
73    pub(in crate::app) sort_mode: TunnelSortMode,
74
75    // Live-data layer. Receivers are drained from `poll()`. The Sender
76    // halves are cloned into per-tunnel parser threads when start_tunnel
77    // succeeds.
78    pub(in crate::app) parser_tx: Sender<ParserMessage>,
79    pub(in crate::app) parser_rx: Receiver<ParserMessage>,
80    pub(in crate::app) lsof_tx: Sender<LsofMessage>,
81    pub(in crate::app) lsof_rx: Receiver<LsofMessage>,
82
83    // Last lsof snapshot, keyed by tunnel bind port. Replaced wholesale
84    // on every successful poll so closed sockets disappear.
85    pub(in crate::app) clients: HashMap<u16, Vec<ClientPeer>>,
86    pub(in crate::app) conflicts: HashMap<u16, PortConflict>,
87    pub(in crate::app) last_lsof_at: Option<Instant>,
88
89    /// Per-peer rolling braille history, pushed once per lsof poll
90    /// arrival from `poll()`. Keyed by `(bind_port, peer.src)` so it
91    /// survives wholesale `clients` replacements as long as the peer is
92    /// still in the new snapshot. Each cell carries one
93    /// `current_rx_bps + current_tx_bps` snapshot from a single lsof
94    /// poll, so the visible window covers `PEER_VIZ_BUCKETS` polls
95    /// (~24-48s on Linux/macOS).
96    pub(in crate::app) peer_viz: HashMap<(u16, String), [u64; PEER_VIZ_BUCKETS]>,
97    /// Wall-clock of the most recent `push_peer_viz` rotation, and the
98    /// one before it. The renderer divides `(now - last) / (last - prev)`
99    /// to derive a smooth phase in `[0, 1]` that drifts the wave
100    /// leftward by exactly one bucket between pushes — adaptive to the
101    /// actual poll interval (which varies on macOS due to nettop
102    /// overhead).
103    pub(in crate::app) peer_viz_last_push: Option<Instant>,
104    pub(in crate::app) peer_viz_prev_push: Option<Instant>,
105
106    // The single shared lsof poller. Lazily started on first tunnel
107    // start; lives until App::Drop. The bind_ports list is cloned on
108    // every poll iteration so updates are eventually consistent.
109    pub(in crate::app) lsof: Option<LsofPollerHandle>,
110
111    /// Demo / test seed. When `App.demo_mode == true` the detail panel
112    /// reads from this map instead of the live counters, so visual
113    /// regression tests are byte-deterministic.
114    pub(in crate::app) demo_live_snapshots: HashMap<String, TunnelLiveSnapshot>,
115}
116
117impl Default for TunnelState {
118    fn default() -> Self {
119        let (parser_tx, parser_rx) = std::sync::mpsc::channel::<ParserMessage>();
120        let (lsof_tx, lsof_rx) = std::sync::mpsc::channel::<LsofMessage>();
121        Self {
122            list: Vec::new(),
123            form: TunnelForm::new(),
124            active: HashMap::new(),
125            form_baseline: None,
126            pending_delete: None,
127            summaries_cache: HashMap::new(),
128            sort_mode: TunnelSortMode::default(),
129            parser_tx,
130            parser_rx,
131            lsof_tx,
132            lsof_rx,
133            clients: HashMap::new(),
134            conflicts: HashMap::new(),
135            last_lsof_at: None,
136            peer_viz: HashMap::new(),
137            peer_viz_last_push: None,
138            peer_viz_prev_push: None,
139            lsof: None,
140            demo_live_snapshots: HashMap::new(),
141        }
142    }
143}
144
145impl Drop for TunnelState {
146    fn drop(&mut self) {
147        if let Some(mut handle) = self.lsof.take() {
148            handle.shutdown();
149        }
150    }
151}
152
153impl TunnelState {
154    pub fn list(&self) -> &[TunnelRule] {
155        &self.list
156    }
157
158    pub fn list_mut(&mut self) -> &mut Vec<TunnelRule> {
159        &mut self.list
160    }
161
162    pub fn form(&self) -> &TunnelForm {
163        &self.form
164    }
165
166    pub fn form_mut(&mut self) -> &mut TunnelForm {
167        &mut self.form
168    }
169
170    pub fn reset_form(&mut self) {
171        self.form = TunnelForm::new();
172    }
173
174    /// Load this host's tunnel directives from the SSH config into `list`.
175    pub fn load_directives(
176        &mut self,
177        config: &crate::ssh_config::model::SshConfigFile,
178        alias: &str,
179    ) {
180        self.list = config.find_tunnel_directives(alias);
181    }
182
183    /// True if the tunnel form differs from its captured baseline.
184    pub fn form_is_dirty(&self) -> bool {
185        match &self.form_baseline {
186            Some(b) => {
187                self.form.tunnel_type != b.tunnel_type
188                    || self.form.bind_port != b.bind_port
189                    || self.form.remote_host != b.remote_host
190                    || self.form.remote_port != b.remote_port
191                    || self.form.bind_address != b.bind_address
192            }
193            None => false,
194        }
195    }
196
197    pub fn active(&self) -> &HashMap<String, ActiveTunnel> {
198        &self.active
199    }
200
201    pub fn active_get(&self, alias: &str) -> Option<&ActiveTunnel> {
202        self.active.get(alias)
203    }
204
205    pub fn active_get_mut(&mut self, alias: &str) -> Option<&mut ActiveTunnel> {
206        self.active.get_mut(alias)
207    }
208
209    pub fn active_contains(&self, alias: &str) -> bool {
210        self.active.contains_key(alias)
211    }
212
213    pub fn active_insert(&mut self, alias: String, tunnel: ActiveTunnel) {
214        self.active.insert(alias, tunnel);
215    }
216
217    pub fn active_remove(&mut self, alias: &str) -> Option<ActiveTunnel> {
218        self.active.remove(alias)
219    }
220
221    pub fn drain_active(&mut self) -> std::collections::hash_map::Drain<'_, String, ActiveTunnel> {
222        self.active.drain()
223    }
224
225    pub fn clear_active(&mut self) {
226        self.active.clear();
227    }
228
229    pub fn pending_delete(&self) -> Option<usize> {
230        self.pending_delete
231    }
232
233    pub fn take_pending_delete(&mut self) -> Option<usize> {
234        self.pending_delete.take()
235    }
236
237    pub fn sort_mode(&self) -> TunnelSortMode {
238        self.sort_mode
239    }
240
241    pub fn set_sort_mode(&mut self, mode: TunnelSortMode) {
242        self.sort_mode = mode;
243    }
244
245    pub fn form_baseline(&self) -> Option<&TunnelFormBaseline> {
246        self.form_baseline.as_ref()
247    }
248
249    pub fn set_form_baseline(&mut self, baseline: Option<TunnelFormBaseline>) {
250        self.form_baseline = baseline;
251    }
252
253    pub fn demo_live_snapshots(&self) -> &HashMap<String, crate::tunnel_live::TunnelLiveSnapshot> {
254        &self.demo_live_snapshots
255    }
256
257    pub fn demo_live_snapshots_mut(
258        &mut self,
259    ) -> &mut HashMap<String, crate::tunnel_live::TunnelLiveSnapshot> {
260        &mut self.demo_live_snapshots
261    }
262
263    pub fn parser_tx(&self) -> Sender<ParserMessage> {
264        self.parser_tx.clone()
265    }
266
267    pub fn clients(&self) -> &HashMap<u16, Vec<ClientPeer>> {
268        &self.clients
269    }
270
271    pub fn peer_viz(&self) -> &HashMap<(u16, String), [u64; PEER_VIZ_BUCKETS]> {
272        &self.peer_viz
273    }
274
275    pub fn peer_viz_last_push(&self) -> Option<Instant> {
276        self.peer_viz_last_push
277    }
278
279    pub fn peer_viz_prev_push(&self) -> Option<Instant> {
280        self.peer_viz_prev_push
281    }
282
283    pub fn summaries_cache(&self) -> &HashMap<String, String> {
284        &self.summaries_cache
285    }
286
287    pub fn summaries_cache_mut(&mut self) -> &mut HashMap<String, String> {
288        &mut self.summaries_cache
289    }
290
291    /// Open a delete confirmation for the tunnel at `idx`. The renderer
292    /// reads `pending_delete` to draw the confirm overlay.
293    pub fn request_delete(&mut self, idx: usize) {
294        self.pending_delete = Some(idx);
295    }
296
297    /// Dismiss a pending delete confirmation. Idempotent.
298    pub fn cancel_delete(&mut self) {
299        self.pending_delete = None;
300    }
301
302    /// Ensure the shared lsof poller is running. Idempotent: a second
303    /// call after the poller is already up is a noop. Caller is
304    /// responsible for updating `bind_ports` afterwards.
305    pub fn ensure_lsof_poller(&mut self) {
306        if self.lsof.is_some() {
307            return;
308        }
309        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
310        let bind_ports: Arc<Mutex<Vec<(String, u16, u32)>>> = Arc::new(Mutex::new(Vec::new()));
311        let thread = crate::tunnel_live::spawn_lsof_poller(
312            bind_ports.clone(),
313            self.lsof_tx.clone(),
314            stop.clone(),
315        );
316        self.lsof = Some(LsofPollerHandle {
317            stop,
318            bind_ports,
319            thread: Some(thread),
320        });
321        log::debug!("[purple] Tunnel lsof poller started");
322    }
323
324    /// Replace the lsof poller's port list. Callers compute the
325    /// `(alias, bind_port, tunnel_pid)` tuples from the SSH config
326    /// directives because `ActiveTunnel` does not store the rule set
327    /// directly. The poller picks up the new list on its next iteration.
328    pub fn set_lsof_ports(&self, ports: Vec<(String, u16, u32)>) {
329        if let Some(handle) = &self.lsof
330            && let Ok(mut g) = handle.bind_ports.lock()
331        {
332            *g = ports;
333        }
334    }
335
336    /// Drain the parser channel into per-tunnel live state, drain the
337    /// lsof channel into the shared `clients` and `conflicts` maps,
338    /// rotate per-tunnel history buckets and finally poll every active
339    /// child for exit. Returns one `TunnelExit` per tunnel that ended so the
340    /// outer loop can route it through `notify_*`.
341    pub fn poll(&mut self) -> Vec<TunnelExit> {
342        let now = Instant::now();
343        // Drain channel events first so any pending opens are reflected
344        // before we report exits.
345        while let Ok(msg) = self.parser_rx.try_recv() {
346            if let Some(tunnel) = self.active.get_mut(&msg.alias) {
347                tunnel.live.record_event(msg.event);
348            }
349        }
350        // Drain lsof snapshots — keep only the freshest one. Older
351        // pending messages are discarded because they would just
352        // overwrite each other.
353        let mut latest_lsof: Option<LsofMessage> = None;
354        while let Ok(msg) = self.lsof_rx.try_recv() {
355            latest_lsof = Some(msg);
356        }
357        if let Some(msg) = latest_lsof {
358            self.clients = msg.clients;
359            self.conflicts = msg.conflicts;
360            self.last_lsof_at = Some(msg.at);
361            // Roll the per-peer braille history forward exactly once
362            // per lsof arrival. The renderer derives a smooth phase
363            // from the timestamp pair below to fill in motion between
364            // pushes at terminal frame rate.
365            self.push_peer_viz(now);
366        }
367        // Rotate per-tunnel history. Bucket width is `BUCKET_SECS`
368        // (currently 2s), so this is effectively per-poll rotation.
369        for tunnel in self.active.values_mut() {
370            tunnel.live.rotate_if_due(now);
371        }
372        // Build a port → alias map once and reuse it for both the
373        // throughput aggregation and the concurrent-activity sampling
374        // below. Source: the lsof poller's `(alias, port, pid)` view of
375        // the active bind ports.
376        let port_to_alias: HashMap<u16, String> = self
377            .lsof
378            .as_ref()
379            .and_then(|h| h.bind_ports.lock().ok().map(|g| g.clone()))
380            .map(|v| v.into_iter().map(|(a, p, _)| (p, a)).collect())
381            .unwrap_or_default();
382        // Aggregate per-peer current bps into per-tunnel readouts. The
383        // tunnel-level value is the honest sum of every connected
384        // client's flow — it matches the per-peer numbers shown in the
385        // roster. The previous SSH-process sampler counted both ends
386        // of the loopback hop, which doubled the displayed speed.
387        let mut bps_per_alias: HashMap<String, (u64, u64, bool)> = HashMap::new();
388        for (port, peers) in &self.clients {
389            let Some(alias) = port_to_alias.get(port) else {
390                continue;
391            };
392            let entry = bps_per_alias
393                .entry(alias.clone())
394                .or_insert((0u64, 0u64, false));
395            for peer in peers {
396                entry.0 = entry.0.saturating_add(peer.current_rx_bps);
397                entry.1 = entry.1.saturating_add(peer.current_tx_bps);
398                if peer.last_sample_at.is_some() {
399                    entry.2 = true;
400                }
401            }
402        }
403        for (alias, tunnel) in self.active.iter_mut() {
404            let (rx, tx, ready) = bps_per_alias.get(alias).copied().unwrap_or((0, 0, false));
405            tunnel.live.current_rx_bps = rx;
406            tunnel.live.current_tx_bps = tx;
407            tunnel.live.peak_rx_bps = tunnel.live.peak_rx_bps.max(rx);
408            tunnel.live.peak_tx_bps = tunnel.live.peak_tx_bps.max(tx);
409            if ready {
410                tunnel.live.last_throughput_at = Some(now);
411            }
412        }
413        // Sample concurrent activity per alias into the current bucket.
414        // Source = max(lsof ESTABLISHED clients, ssh active channels)
415        // summed across every bind_port that belongs to the alias. That
416        // way the sparkline reflects ongoing concurrency (a long-lived
417        // WebKit connection, a streaming HTTP/2 session) rather than
418        // only short channel-open bursts.
419        let mut concurrent_per_alias: HashMap<String, u32> = HashMap::new();
420        for (port, peers) in &self.clients {
421            if let Some(alias) = port_to_alias.get(port) {
422                *concurrent_per_alias.entry(alias.clone()).or_insert(0) += peers.len() as u32;
423            }
424        }
425        for (alias, tunnel) in self.active.iter_mut() {
426            let lsof_count = concurrent_per_alias.get(alias).copied().unwrap_or(0);
427            let sample = lsof_count.max(tunnel.live.active_channels);
428            tunnel.live.sample_activity(sample);
429        }
430
431        if self.active.is_empty() {
432            return Vec::new();
433        }
434        let mut exited = Vec::new();
435        let mut to_remove = Vec::new();
436        for (alias, tunnel) in &mut self.active {
437            match tunnel.child.try_wait() {
438                Ok(Some(status)) => {
439                    // Give the parser a moment to read ssh's last lines, so
440                    // the exit is judged on everything ssh wrote.
441                    let exited_at = *tunnel.live.exited_at.get_or_insert(now);
442                    if !tunnel.live.drained.load(Ordering::Relaxed)
443                        && now.saturating_duration_since(exited_at) < EXIT_DRAIN_GRACE
444                    {
445                        continue;
446                    }
447                    // The parser thread holds child.stderr; ask the
448                    // shared stderr ringbuffer for the last meaningful
449                    // line instead of re-reading the pipe.
450                    let stderr_msg = tunnel
451                        .live
452                        .stderr_buffer
453                        .lock()
454                        .ok()
455                        .and_then(|b| b.iter().rev().find(|s| !s.trim().is_empty()).cloned())
456                        .map(|s| s.trim().to_string())
457                        .filter(|s| !s.is_empty());
458                    let exit_code = status.code().unwrap_or(-1);
459                    if !status.success() {
460                        log::error!(
461                            "[external] Tunnel exited unexpectedly: alias={alias} exit={exit_code}"
462                        );
463                        if let Some(ref err) = stderr_msg {
464                            log::debug!("[external] Tunnel stderr: {}", err.trim());
465                        }
466                    }
467                    let last_exit_line = stderr_msg
468                        .clone()
469                        .unwrap_or_else(|| format!("exit code {}", exit_code));
470                    tunnel.live.last_exit = Some((exit_code, last_exit_line));
471                    tunnel.live.parser_stop.store(true, Ordering::Relaxed);
472                    // Mark all currently-open channels as closed so the
473                    // active count drops to zero on exit.
474                    if tunnel.live.active_channels > 0 {
475                        let close_now = ChannelEventKind::Close;
476                        let ids: Vec<u32> = tunnel.live.channel_open.keys().copied().collect();
477                        for id in ids {
478                            tunnel.live.record_event(crate::tunnel_live::ChannelEvent {
479                                at: now,
480                                channel_id: id,
481                                kind: close_now,
482                                channel_kind: None,
483                                opened_at: None,
484                            });
485                        }
486                    }
487                    let (msg, is_error) = if status.success() {
488                        (format!("Tunnel for {} closed.", alias), false)
489                    } else if let Some(err) = stderr_msg {
490                        (format!("Tunnel for {}: {}", alias, err), true)
491                    } else {
492                        (
493                            format!("Tunnel for {} exited with code {}.", alias, exit_code),
494                            true,
495                        )
496                    };
497                    exited.push(TunnelExit {
498                        alias: alias.clone(),
499                        message: msg,
500                        is_error,
501                        auth_refused: tunnel.live.auth_refused.load(Ordering::Relaxed),
502                        authenticated: tunnel.live.authenticated.load(Ordering::Relaxed),
503                    });
504                    to_remove.push(alias.clone());
505                }
506                Ok(None) => {}
507                Err(e) => {
508                    exited.push(TunnelExit {
509                        alias: alias.clone(),
510                        message: format!("Tunnel for {} lost: {}", alias, e),
511                        is_error: true,
512                        auth_refused: false,
513                        authenticated: tunnel.live.authenticated.load(Ordering::Relaxed),
514                    });
515                    to_remove.push(alias.clone());
516                }
517            }
518        }
519        for alias in to_remove {
520            self.active.remove(&alias);
521        }
522        exited
523    }
524
525    /// Push one bucket of per-peer braille history. Called exactly
526    /// once per lsof arrival so the visible window encodes
527    /// `PEER_VIZ_BUCKETS` consecutive poll snapshots — long enough to
528    /// see the trend, short enough to react quickly to changes. The
529    /// renderer fills in smooth motion between pushes via
530    /// `peer_viz_last_push` / `peer_viz_prev_push`. Garbage-collects
531    /// entries for peers that no longer appear in `self.clients`.
532    pub fn push_peer_viz(&mut self, now: Instant) {
533        let mut live: HashSet<(u16, String)> = HashSet::new();
534        for (port, peers) in &self.clients {
535            for peer in peers {
536                let key = (*port, peer.src.clone());
537                live.insert(key.clone());
538                let combined = peer.current_rx_bps.saturating_add(peer.current_tx_bps);
539                let history = self
540                    .peer_viz
541                    .entry(key)
542                    .or_insert_with(|| [0u64; PEER_VIZ_BUCKETS]);
543                history.rotate_left(1);
544                history[PEER_VIZ_BUCKETS - 1] = combined;
545            }
546        }
547        self.peer_viz.retain(|key, _| live.contains(key));
548        self.peer_viz_prev_push = self.peer_viz_last_push;
549        self.peer_viz_last_push = Some(now);
550    }
551
552    /// Drop demo-mode tunnel snapshots whose alias is no longer in
553    /// `valid_aliases`. Called from `App::reload_hosts`. Outside demo
554    /// the map stays empty so this is a no-op, but a demo workflow that
555    /// renames or deletes a host should not leak the old snapshot.
556    pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) {
557        let pre = self.demo_live_snapshots.len();
558        self.demo_live_snapshots
559            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
560        let dropped = pre.saturating_sub(self.demo_live_snapshots.len());
561        if dropped > 0 {
562            log::debug!(
563                "[purple] reload_hosts: dropped {dropped} orphan demo_live_snapshots entrie(s)"
564            );
565        }
566    }
567
568    /// Move the active-tunnel handle from `old` to `new` on host
569    /// rename. Called from `App::migrate_alias_keyed_caches` before
570    /// `reload_hosts`, whose prune step would otherwise drop entries
571    /// still under the old alias. No-op when `old == new` or no
572    /// active tunnel exists under `old`.
573    pub fn migrate_alias(&mut self, old: &str, new: &str) {
574        if old == new {
575            return;
576        }
577        if let Some(t) = self.active.remove(old) {
578            self.active.insert(new.to_string(), t);
579        }
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn default_state_is_empty() {
589        let s = TunnelState::default();
590        assert!(s.list.is_empty());
591        assert!(s.active.is_empty());
592        assert!(s.pending_delete.is_none());
593        assert!(s.summaries_cache.is_empty());
594    }
595
596    #[test]
597    fn poll_on_empty_returns_empty_vec() {
598        // Fast path: no active tunnels means no exit events to report and
599        // no child processes to reap. Spawning real ssh child processes
600        // belongs in integration tests.
601        let mut s = TunnelState::default();
602        let result = s.poll();
603        assert!(result.is_empty());
604        assert!(s.active.is_empty());
605    }
606
607    #[cfg(unix)]
608    #[test]
609    fn an_exit_waits_for_the_parser_to_read_the_last_lines() {
610        let mut s = TunnelState::default();
611        // A grandchild keeps stderr open after the shell exits, so the
612        // parser cannot reach the end of the pipe yet.
613        let child = std::process::Command::new("sh")
614            .arg("-c")
615            .arg("sleep 10 >&2 & exit 3")
616            .stderr(std::process::Stdio::piped())
617            .spawn()
618            .expect("spawn sh");
619        let tunnel = crate::tunnel::ActiveTunnel::spawn(
620            child,
621            "h",
622            s.parser_tx(),
623            crate::tunnel_live::AuthWatch::default(),
624        );
625        s.active.insert("h".into(), tunnel);
626        let deadline = Instant::now() + std::time::Duration::from_secs(5);
627        while s
628            .active
629            .get("h")
630            .is_some_and(|t| t.live.exited_at.is_none())
631            && Instant::now() < deadline
632        {
633            assert!(s.poll().is_empty(), "held back while the parser drains");
634            std::thread::sleep(std::time::Duration::from_millis(10));
635        }
636        assert!(s.active.contains_key("h"), "still waiting on the parser");
637        // Pinned, so a slow machine cannot drift past the grace here.
638        s.active.get_mut("h").expect("tunnel").live.exited_at = Some(Instant::now());
639        assert!(s.poll().is_empty(), "inside the grace the exit waits");
640        // Past the grace the exit is reported whether or not the pipe closed.
641        s.active.get_mut("h").expect("tunnel").live.exited_at =
642            Some(Instant::now() - EXIT_DRAIN_GRACE);
643        let exits = s.poll();
644        assert_eq!(exits.len(), 1);
645        assert!(exits[0].is_error, "exit 3 is an error");
646        assert!(!exits[0].auth_refused);
647        assert!(s.active.is_empty());
648    }
649
650    fn make_peer(src: &str, rx: u64, tx: u64) -> ClientPeer {
651        ClientPeer {
652            src: src.to_string(),
653            process: "curl".into(),
654            pid: 1234,
655            since: Instant::now(),
656            responsible_app: None,
657            current_rx_bps: rx,
658            current_tx_bps: tx,
659            bytes_rcvd: None,
660            bytes_sent: None,
661            last_sample_at: Some(Instant::now()),
662        }
663    }
664
665    #[test]
666    fn push_peer_viz_initialises_history_and_writes_combined_bps_to_rightmost_cell() {
667        let mut s = TunnelState::default();
668        s.clients
669            .insert(8080, vec![make_peer("127.0.0.1:1", 1_000, 500)]);
670        let now = Instant::now();
671        s.push_peer_viz(now);
672        let key = (8080u16, "127.0.0.1:1".to_string());
673        let history = s.peer_viz.get(&key).expect("entry created on first push");
674        assert_eq!(history[PEER_VIZ_BUCKETS - 1], 1_500);
675        for cell in &history[..PEER_VIZ_BUCKETS - 1] {
676            assert_eq!(*cell, 0);
677        }
678        assert_eq!(s.peer_viz_last_push, Some(now));
679        assert_eq!(s.peer_viz_prev_push, None);
680    }
681
682    #[test]
683    fn push_peer_viz_rotates_left_on_each_call() {
684        let mut s = TunnelState::default();
685        s.clients
686            .insert(8080, vec![make_peer("127.0.0.1:1", 100, 0)]);
687        let t0 = Instant::now();
688        s.push_peer_viz(t0);
689        // Update the bps reading and push again to simulate a second
690        // lsof arrival.
691        if let Some(peers) = s.clients.get_mut(&8080) {
692            peers[0].current_rx_bps = 200;
693        }
694        let t1 = t0 + std::time::Duration::from_secs(2);
695        s.push_peer_viz(t1);
696        let key = (8080u16, "127.0.0.1:1".to_string());
697        let history = s.peer_viz.get(&key).expect("entry exists");
698        assert_eq!(history[PEER_VIZ_BUCKETS - 1], 200);
699        assert_eq!(history[PEER_VIZ_BUCKETS - 2], 100);
700        // Both timestamps populated so the renderer can derive a
701        // smooth phase from the actual interval.
702        assert_eq!(s.peer_viz_last_push, Some(t1));
703        assert_eq!(s.peer_viz_prev_push, Some(t0));
704    }
705
706    #[test]
707    fn push_peer_viz_garbage_collects_disappeared_peers() {
708        let mut s = TunnelState::default();
709        s.clients.insert(8080, vec![make_peer("127.0.0.1:1", 0, 0)]);
710        let t0 = Instant::now();
711        s.push_peer_viz(t0);
712        assert!(
713            s.peer_viz
714                .contains_key(&(8080u16, "127.0.0.1:1".to_string()))
715        );
716        // Peer disappears from the lsof snapshot on the next poll.
717        s.clients.clear();
718        s.push_peer_viz(t0 + std::time::Duration::from_secs(2));
719        assert!(s.peer_viz.is_empty());
720    }
721
722    #[test]
723    fn request_delete_sets_pending_delete_to_some_idx() {
724        let mut s = TunnelState::default();
725        s.request_delete(3);
726        assert_eq!(s.pending_delete, Some(3));
727    }
728
729    #[test]
730    fn cancel_delete_clears_pending_delete() {
731        let mut s = TunnelState::default();
732        s.pending_delete = Some(2);
733        s.cancel_delete();
734        assert!(s.pending_delete.is_none());
735    }
736
737    #[test]
738    fn request_delete_overwrites_existing_pending() {
739        let mut s = TunnelState::default();
740        s.pending_delete = Some(1);
741        s.request_delete(7);
742        assert_eq!(s.pending_delete, Some(7));
743    }
744
745    #[test]
746    fn cancel_delete_is_idempotent_on_empty_pending() {
747        let mut s = TunnelState::default();
748        s.cancel_delete();
749        s.cancel_delete();
750        assert!(s.pending_delete.is_none());
751    }
752
753    fn empty_snapshot() -> crate::tunnel_live::TunnelLiveSnapshot {
754        crate::tunnel_live::TunnelLiveSnapshot {
755            uptime_secs: 0,
756            active_channels: 0,
757            peak_concurrent: 0,
758            total_opens: 0,
759            idle_secs: 0,
760            rx_history: [0; crate::tunnel_live::HISTORY_BUCKETS],
761            tx_history: [0; crate::tunnel_live::HISTORY_BUCKETS],
762            current_rx_bps: 0,
763            current_tx_bps: 0,
764            peak_rx_bps: 0,
765            peak_tx_bps: 0,
766            throughput_ready: false,
767            clients: vec![],
768            events: vec![],
769            currently_open: vec![],
770            conflict: None,
771            last_exit: None,
772        }
773    }
774
775    #[test]
776    fn prune_orphans_drops_unknown_demo_snapshots() {
777        let mut s = TunnelState::default();
778        s.demo_live_snapshots
779            .insert("keep".to_string(), empty_snapshot());
780        s.demo_live_snapshots
781            .insert("drop".to_string(), empty_snapshot());
782
783        let valid: HashSet<&str> = ["keep"].into_iter().collect();
784        s.prune_orphans(&valid);
785
786        assert!(s.demo_live_snapshots.contains_key("keep"));
787        assert!(!s.demo_live_snapshots.contains_key("drop"));
788    }
789
790    #[test]
791    fn migrate_alias_is_noop_on_empty_active_map() {
792        // Constructing a real ActiveTunnel requires a spawned child
793        // process; the active-map rename is covered by the
794        // `migrate_alias_keyed_caches_*` integration tests, which build
795        // a full App. Here we just verify the no-op contract on absent
796        // and self-rename inputs.
797        let mut s = TunnelState::default();
798        s.migrate_alias("missing", "new");
799        assert!(s.active.is_empty());
800        s.migrate_alias("same", "same");
801        assert!(s.active.is_empty());
802    }
803
804    fn state_matching_baseline() -> TunnelState {
805        let mut s = TunnelState::default();
806        s.form.tunnel_type = crate::tunnel::TunnelType::Local;
807        s.form.bind_port = "8080".into();
808        s.form.remote_host = "db.internal".into();
809        s.form.remote_port = "5432".into();
810        s.form.bind_address = "127.0.0.1".into();
811        s.set_form_baseline(Some(TunnelFormBaseline {
812            tunnel_type: crate::tunnel::TunnelType::Local,
813            bind_port: "8080".into(),
814            remote_host: "db.internal".into(),
815            remote_port: "5432".into(),
816            bind_address: "127.0.0.1".into(),
817        }));
818        s
819    }
820
821    #[test]
822    fn form_is_dirty_is_false_without_a_baseline() {
823        let mut s = TunnelState::default();
824        s.form.bind_port = "9000".into();
825        assert!(!s.form_is_dirty());
826    }
827
828    #[test]
829    fn form_is_dirty_is_false_when_form_equals_baseline() {
830        assert!(!state_matching_baseline().form_is_dirty());
831    }
832
833    fn assert_field_change_is_dirty(field: &str, mutate: impl FnOnce(&mut TunnelForm)) {
834        let mut s = state_matching_baseline();
835        mutate(&mut s.form);
836        assert!(s.form_is_dirty(), "a change in {field} must read dirty");
837    }
838
839    #[test]
840    fn form_is_dirty_detects_a_change_in_each_field() {
841        assert_field_change_is_dirty("tunnel_type", |f| {
842            f.tunnel_type = crate::tunnel::TunnelType::Remote;
843        });
844        assert_field_change_is_dirty("bind_port", |f| f.bind_port.push('1'));
845        assert_field_change_is_dirty("remote_host", |f| f.remote_host.push('x'));
846        assert_field_change_is_dirty("remote_port", |f| f.remote_port.push('1'));
847        assert_field_change_is_dirty("bind_address", |f| f.bind_address.push('9'));
848    }
849}