Skip to main content

taimux_cli/
remote.rs

1//! Other hosts' sessions, in the same list.
2//!
3//! A pane holding `ssh <host> -t tmux …` is a window onto a whole other tmux
4//! server, and the agents running in it are invisible to
5//! everything else here: they are not this server's panes and not this box's
6//! processes. But that host has a taimux of its own, and `list` is a complete
7//! answer about it.
8//!
9//! **The rule that makes this safe is: federate, never reach in.** Each host is
10//! asked about ITSELF, so the hook state, the screen reading, the versions and
11//! the transcripts all stay on the side that can see them, and `list` is
12//! deliberately local-only because it is the wire format. That one rule is what
13//! makes a cycle between two boxes ssh'd into each other structurally impossible
14//! rather than merely unlikely.
15//!
16//! **Hosts are DISCOVERED, not configured**: whatever the local panes are already
17//! ssh'd into is the list. That is not just less setup. A host found this way has
18//! a warm ssh ControlMaster by construction (the pane's own connection), so a
19//! fetch is a tenth of a second rather than a handshake; it drops off the list
20//! when its pane goes; and it arrives with its jump target already identified,
21//! since the pane it was found in IS the way back to it.
22//!
23//! Nothing on the refresh path waits on the network except a host with NOTHING
24//! cached, which is fetched once per boot so the first picker after a reboot is
25//! complete. Everything else is served as it stands and refreshed behind the
26//! picker.
27
28use std::io::Read;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
32
33use taimux_core::{env, index, proc};
34
35/// Rebuilding PATH on the far side, because `taimux` is NOT on a
36/// non-interactive ssh's PATH on a stock install: `~/.local/bin` is
37/// login-shell-only, so `ssh ha taimux list` fails where
38/// `ssh ha '~/.local/bin/taimux list'` works. It also covers a host that has
39/// taimux only as a tmux plugin, and it exits 127 when there is none, which is
40/// how "no taimux over there" is told apart from "unreachable".
41const REMOTE_TAIMUX: &str = "PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; \
42     for d in \"$HOME\"/.tmux/plugins/taimux*/ \"$HOME\"/.config/tmux/plugins/taimux*/; do \
43     [ -x \"$d/taimux\" ] && PATH=\"$PATH:${d%/}\"; done; \
44     command -v taimux >/dev/null 2>&1 || exit 127; exec taimux";
45
46/// The preamble, for a caller that needs to build its own remote command.
47pub fn remote_taimux() -> &'static str {
48    REMOTE_TAIMUX
49}
50
51/// The subcommand `index_fetch` asks a remote host for.
52///
53/// Named rather than inlined so the test below can assert it against the
54/// dispatcher, because nothing else can: a wrong name here is answered with
55/// `no such command` and exit 1, which `index_fetch` cannot tell apart from an
56/// old host with nothing to say. It stayed wrong for exactly that reason.
57const INDEX_DUMP_CMD: &str = "index-dump";
58
59fn now() -> i64 {
60    SystemTime::now()
61        .duration_since(UNIX_EPOCH)
62        .map(|d| d.as_secs() as i64)
63        .unwrap_or(0)
64}
65
66pub fn enabled() -> bool {
67    env::on("TAIMUX_REMOTE")
68}
69
70/// Run something with a deadline, and kill it when the deadline passes.
71///
72/// The time limit is the ONLY way out of a popup whose helper wedges: a child
73/// that swallows ctrl-c cannot be escaped from, because the parent waits for it
74/// either way. So nothing on the picker's path may be given a long one.
75fn bounded(secs: u64, mut cmd: Command) -> (i32, String) {
76    let Ok(mut child) = cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else {
77        return (255, String::new());
78    };
79    let deadline = Instant::now() + Duration::from_secs(secs);
80    let mut out = child.stdout.take();
81    loop {
82        match child.try_wait() {
83            Ok(Some(st)) => {
84                let mut s = String::new();
85                if let Some(o) = out.as_mut() {
86                    let _ = o.read_to_string(&mut s);
87                }
88                return (st.code().unwrap_or(255), s);
89            }
90            Ok(None) => {
91                if Instant::now() >= deadline {
92                    let _ = child.kill();
93                    let _ = child.wait();
94                    return (124, String::new()); // the status `timeout` uses
95                }
96                std::thread::sleep(Duration::from_millis(20));
97            }
98            Err(_) => return (255, String::new()),
99        }
100    }
101}
102
103/// One ssh, bounded, batch-mode, and never reading stdin: `-n` matters because
104/// this runs behind a picker that owns the terminal.
105pub fn ssh(host: &str, remote: &str, timeout: u64) -> (i32, String) {
106    let mut c = Command::new("ssh");
107    c.args([
108        "-n",
109        "-o",
110        "BatchMode=yes",
111        "-o",
112        &format!(
113            "ConnectTimeout={}",
114            env::num("TAIMUX_SSH_CONNECT_TIMEOUT", 2)
115        ),
116        "--",
117        host,
118        remote,
119    ]);
120    taimux_core::stat::ssh(|| bounded(timeout, c))
121}
122
123/// Why an ssh failed, in the words the row will carry.
124pub fn ssh_why(status: i32) -> &'static str {
125    match status {
126        127 => "no taimux on that host",
127        124 | 137 => "timed out",
128        255 => "unreachable",
129        _ => "ssh failed",
130    }
131}
132
133/// The host an `ssh … tmux …` argv connects to, or None.
134///
135/// Finds the ssh token, then takes the first bare word after it as the host,
136/// skipping options and the values of the ones that take a separate argument.
137/// `tmux` has to appear AFTER that: it is what separates a pane holding a nested
138/// SERVER from one merely running a remote command (`ssh host journalctl -f`),
139/// and from a plain remote login shell, neither of which has anything to
140/// federate.
141///
142/// **ssh is looked for as a TOKEN rather than as argv[0]**, because it routinely
143/// is not argv[0]: when a `#!` script is named ssh (a ProxyCommand or kerberos
144/// wrapper, and the stand-in the demo uses) the kernel runs it as
145/// `bash /path/to/ssh host …`, so the interpreter holds argv[0] and requiring
146/// that to read "ssh" silently found no hosts at all on such a machine.
147pub fn ssh_host(argv: &str) -> Option<String> {
148    // the options that take a separate value, so the value is not read as a host
149    const TAKES_VALUE: &str = "BbcDEeFIiJLlmOopQRSWw";
150    let toks: Vec<&str> = argv.split(' ').filter(|t| !t.is_empty()).collect();
151    let mut saw_ssh = false;
152    let mut host: Option<&str> = None;
153    let mut saw_tmux = false;
154    let mut i = 0;
155    while i < toks.len() {
156        let tok = toks[i];
157        if !saw_ssh {
158            let base = tok.rsplit('/').next().unwrap_or(tok);
159            if base == "ssh" {
160                saw_ssh = true;
161            }
162            i += 1;
163            continue;
164        }
165        if host.is_none() {
166            if tok.starts_with('-') {
167                let mut c = tok.chars();
168                c.next();
169                if let (Some(f), None) = (c.next(), c.next()) {
170                    if TAKES_VALUE.contains(f) {
171                        i += 1; // its value is not a host
172                    }
173                }
174                i += 1;
175                continue;
176            }
177            host = Some(tok);
178            i += 1;
179            continue;
180        }
181        if tok == "tmux" {
182            saw_tmux = true;
183        }
184        i += 1;
185    }
186    let host = host?;
187    if !saw_tmux {
188        return None;
189    }
190    // Anything unexpected in a host is dropped rather than escaped: the name
191    // becomes a cache filename and an ssh argument, and there is no such thing as
192    // a hostname needing a quote.
193    if host.is_empty()
194        || !host
195            .bytes()
196            .all(|b| b.is_ascii_alphanumeric() || b"._@-".contains(&b))
197    {
198        return None;
199    }
200    Some(host.to_string())
201}
202
203/// host and the local pane it is reached through, for every ssh pane.
204pub fn ssh_tmux_panes(panes: &[(String, String)]) -> Vec<(String, String)> {
205    // The foreground argv per tty, which is the same scan the pane list uses. A
206    // tty can carry more than one foreground-group process, so every one of them
207    // is tried before the pane is written off.
208    let fg = proc::foreground_map();
209    let mut out = Vec::new();
210    for (id, tty) in panes {
211        let tty = tty.trim_start_matches("/dev/");
212        for f in fg.iter().filter(|f| f.tty == tty) {
213            if let Some(h) = ssh_host(&f.argv) {
214                out.push((h, id.clone()));
215                break;
216            }
217        }
218    }
219    out
220}
221
222pub fn cache_dir() -> PathBuf {
223    taimux_core::paths::runtime_dir().join("remote")
224}
225
226/// The host-list cache, keyed by tmux SERVER.
227///
228/// Keyed because that is what it is a property of: the list comes off that
229/// server's panes, and a second server on the same box (the demo runs one) has
230/// entirely different ones.
231fn hosts_file(dir: &Path) -> PathBuf {
232    let sock = std::env::var("TMUX").unwrap_or_default();
233    let sock = sock.split(',').next().unwrap_or("").to_string();
234    if sock.is_empty() {
235        dir.join(".hosts")
236    } else {
237        dir.join(format!(".hosts.{}", sock.replace('/', "%")))
238    }
239}
240
241/// Write a file by rename, because two pickers can be open at once and half a
242/// file is worse than an old one.
243fn write_atomic(f: &Path, body: &str) {
244    if let Some(d) = f.parent() {
245        let _ = std::fs::create_dir_all(d);
246    }
247    let tmp = f.with_extension(format!("t{}", std::process::id()));
248    if std::fs::write(&tmp, body).is_ok() && !body.is_empty() && std::fs::rename(&tmp, f).is_ok() {
249        return;
250    }
251    let _ = std::fs::remove_file(&tmp);
252}
253
254/// A cached file's header: `<status> <epoch> [reason]`.
255///
256/// One line, so a reader learns both what happened and how old it is from ONE
257/// read: no stat, no fork. That matters because the freshness test runs per host
258/// on every refresh tick.
259fn header(f: &Path) -> Option<(String, i64, String)> {
260    let text = std::fs::read_to_string(f).ok()?;
261    let line = text.lines().next()?;
262    let mut it = line.splitn(3, ' ');
263    let status = it.next()?.to_string();
264    let stamp = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
265    Some((status, stamp, it.next().unwrap_or("").to_string()))
266}
267
268/// The discovered hosts, cached.
269///
270/// Discovery is a full process scan and it answers the same thing tick after
271/// tick, since panes do not open and close on the timescale a picker refreshes
272/// on. Caching it keeps the steady-state cost of federation to reading a few
273/// small files, and keeps that cost OFF everyone else: without it a server with
274/// no ssh panes at all still pays a scan on every refresh to find that out.
275pub fn hosts_cached(dir: &Path, panes: &[(String, String)]) -> Vec<String> {
276    let f = hosts_file(dir);
277    if let Some((status, stamp, _)) = header(&f) {
278        if status == "hosts" && now() - stamp < env::num("TAIMUX_HOSTS_TTL", 5) as i64 {
279            return std::fs::read_to_string(&f)
280                .unwrap_or_default()
281                .lines()
282                .skip(1)
283                .filter(|l| !l.is_empty())
284                .map(|l| l.to_string())
285                .collect();
286        }
287    }
288    let mut hosts: Vec<String> = ssh_tmux_panes(panes).into_iter().map(|(h, _)| h).collect();
289    hosts.sort();
290    hosts.dedup();
291    // The header line is unconditional, so the file is never empty and the write
292    // is never mistaken for a failure. bash got this wrong the other way round:
293    // its `if { … } > tmp` tested the LAST command in the group, which was the
294    // "are there any hosts?" guard, so on every server with nothing to federate
295    // the write read as failed, the cache was never written, and each refresh both
296    // re-ran the scan the cache exists to avoid and left its temp file behind.
297    write_atomic(&f, &format!("hosts {}\n{}\n", now(), hosts.join("\n")));
298    hosts
299}
300
301/// Ask one host about itself, and write down what it said.
302pub fn fetch(host: &str, dir: &Path) {
303    let (rc, out) = ssh(
304        host,
305        &format!("{} list", REMOTE_TAIMUX),
306        env::num("TAIMUX_SSH_TIMEOUT", 4),
307    );
308    let f = dir.join(host);
309    if rc == 0 {
310        write_atomic(&f, &format!("ok {}\n{}", now(), out));
311        // A host that has answered ONCE is a participant, and from then on its
312        // silence is worth a row. One that has never answered is simply not
313        // running taimux, which is a permanent and perfectly fine state of
314        // affairs rather than an outage, so it stays out of the list entirely
315        // instead of parking a complaint in it. Install taimux there and the
316        // host joins on its own; that is the whole of the configuration.
317        let _ = std::fs::write(dir.join(format!("{}.seen", host)), "");
318    } else {
319        write_atomic(&f, &format!("err {} {}\n", now(), ssh_why(rc)));
320    }
321}
322
323/// One host's cached reply, turned into rows.
324///
325/// A row's pane id becomes `<host>:<pane>`. The host rides INSIDE the id rather
326/// than in a column of its own because that id is the only thing the picker
327/// carries downstream, so one composite string keeps preview, switch and restart
328/// unchanged and costs `list` no schema change.
329pub fn render(host: &str, dir: &Path) -> String {
330    let f = dir.join(host);
331    let Some((status, _, reason)) = header(&f) else {
332        return String::new();
333    };
334    let text = std::fs::read_to_string(&f).unwrap_or_default();
335    let mut s = String::new();
336    if status == "ok" {
337        let mut bad = 0;
338        for line in text.lines().skip(1) {
339            if line.is_empty() {
340                continue;
341            }
342            if line.split('\t').count() == 8 {
343                s.push_str(&format!("{}:{}\n", host, line));
344            } else {
345                bad += 1;
346            }
347        }
348        // An older taimux over there answers in another shape. Say so on one row
349        // rather than dropping its sessions silently.
350        if bad > 0 {
351            s.push_str(&format!(
352                "{}:!\t{}\t-\t-\t\tunknown\t-\tits taimux answers in another format ({} row(s) dropped)\n",
353                host, host, bad
354            ));
355        }
356    } else if dir.join(format!("{}.seen", host)).exists() {
357        s.push_str(&format!(
358            "{}:!\t{}\t-\t-\t\tunknown\t-\t{}\n",
359            host,
360            host,
361            if reason.is_empty() {
362                "unreachable"
363            } else {
364                &reason
365            }
366        ));
367    }
368    s
369}
370
371/// Which hosts need asking, and how urgently.
372///
373/// Split out so the TTLs can be asserted without an ssh anywhere near it. The
374/// two are deliberately far apart: an answer is worth three seconds, a FAILURE
375/// sixty, because a sleeping host must not be re-probed on every tick while a
376/// live one must not go stale.
377///
378/// A file with no header at all, or a header this does not recognise, is COLD
379/// rather than stale: it has never answered, so serving it from cache would mean
380/// serving nothing.
381fn classify(hosts: &[String], dir: &Path, n: i64) -> (Vec<String>, Vec<String>) {
382    let (mut cold, mut stale) = (Vec::new(), Vec::new());
383    for h in hosts {
384        match header(&dir.join(h)) {
385            None => cold.push(h.clone()),
386            Some((status, stamp, _)) => {
387                let ttl = match status.as_str() {
388                    "ok" => env::num("TAIMUX_REMOTE_TTL", 3),
389                    "err" => env::num("TAIMUX_REMOTE_FAIL_TTL", 60),
390                    _ => {
391                        cold.push(h.clone());
392                        continue;
393                    }
394                };
395                if n - stamp >= ttl as i64 {
396                    stale.push(h.clone());
397                }
398            }
399        }
400    }
401    (cold, stale)
402}
403
404/// Every discovered host's rows.
405pub fn rows(panes: &[(String, String)]) -> String {
406    if !enabled() {
407        return String::new();
408    }
409    let dir = cache_dir();
410    let hosts = hosts_cached(&dir, panes);
411    if hosts.is_empty() {
412        return String::new();
413    }
414
415    let (cold, stale) = classify(&hosts, &dir, now());
416
417    // Nothing cached at all: fetched NOW, and in parallel, so n hosts cost one
418    // round trip rather than n. Serving that host from cache would mean serving
419    // nothing, and the first picker opened after a reboot would come up missing
420    // exactly the remote sessions it was opened to find. Once per host per boot.
421    let mut handles = Vec::new();
422    for h in cold {
423        let d = dir.clone();
424        handles.push(std::thread::spawn(move || fetch(&h, &d)));
425    }
426    for j in handles {
427        let _ = j.join();
428    }
429
430    // An answer that is merely ageing is served as it stands and refreshed
431    // BEHIND the picker, so the wait lands on the next tick instead of on this
432    // one. Detached, and nothing here waits for them.
433    for h in stale {
434        let d = dir.clone();
435        std::thread::spawn(move || fetch(&h, &d));
436    }
437
438    hosts.iter().map(|h| render(h, &dir)).collect()
439}
440
441/// The pane the picker was opened from, resolved through an ssh window.
442///
443/// If that pane is a window onto another host, the row you are on is over THERE,
444/// not the ssh pane you are looking through.
445pub fn resolve_cur(pane: &str, panes: &[(String, String)], pane_cmd: &str) -> String {
446    if !pane.starts_with('%') || !enabled() {
447        return pane.to_string();
448    }
449    // Cheap pre-filter: only a pane whose foreground is ssh can be a window onto
450    // another server, and the scan behind that question is a process listing.
451    if pane_cmd != "ssh" {
452        return pane.to_string();
453    }
454    let Some((host, _)) = ssh_tmux_panes(panes).into_iter().find(|(_, p)| p == pane) else {
455        return pane.to_string();
456    };
457    // A host already known to be down is not asked again: the list has just
458    // waited on that same host, and paying its timeout a second time to place a
459    // cursor is not a trade worth making.
460    if let Some((status, _, _)) = header(&cache_dir().join(&host)) {
461        if status == "err" {
462            return pane.to_string();
463        }
464    }
465    let (rc, out) = ssh(
466        &host,
467        "tmux display-message -p \"#{pane_id}\"",
468        env::num("TAIMUX_SSH_CUR_TIMEOUT", 2),
469    );
470    let rid = out.trim();
471    if rc == 0 && rid.starts_with('%') {
472        format!("{}:{}", host, rid)
473    } else {
474        pane.to_string()
475    }
476}
477
478/// This host's index, for another host to read: `<pane> \t <blob>` per line.
479///
480/// Only LOCAL panes, which is the same "federate, never reach in" rule: a host
481/// must never answer with another host's rows.
482pub fn index_dump() -> String {
483    let mut s = String::new();
484    for e in std::fs::read_dir(index::index_dir())
485        .into_iter()
486        .flatten()
487        .flatten()
488    {
489        let Ok(text) = std::fs::read_to_string(e.path()) else {
490            continue;
491        };
492        let mut lines = text.lines();
493        let Some(head) = lines.next() else { continue };
494        let f: Vec<&str> = head.split(' ').collect();
495        if f.first() != Some(&"idx") || f.len() < 4 {
496            continue;
497        }
498        let pane = f[3];
499        if !(pane.starts_with('%') && pane[1..].bytes().all(|b| b.is_ascii_digit())) {
500            continue;
501        }
502        s.push_str(&format!("{}\t{}\n", pane, lines.next().unwrap_or("")));
503    }
504    s
505}
506
507/// Fetch one host's index and write it under composite ids.
508///
509/// A host whose taimux predates all of this answers with a usage line on stderr
510/// and nothing here, so nothing is written and its rows go on matching what they
511/// show. The guard on the pane id is what makes that a non-event.
512///
513/// That tolerance is also how this went unnoticed: the request used to carry an
514/// underscore-prefixed bash-era name no Rust build has ever implemented, so EVERY
515/// host answered `no such command` and exited 1, and the `rc != 0` below read it
516/// as "an old host, nothing to see". The name has to match a subcommand in
517/// main.rs exactly, which is why it is the named constant below and why the
518/// suite asserts the old spelling appears nowhere in this file.
519pub fn index_fetch(host: &str, dir: &Path) {
520    let (rc, out) = ssh(
521        host,
522        &format!("{} {}", REMOTE_TAIMUX, INDEX_DUMP_CMD),
523        env::num("TAIMUX_SEARCH_SSH_TIMEOUT", 20),
524    );
525    if rc != 0 {
526        return;
527    }
528    let n = now();
529    for line in out.lines() {
530        let Some((pane, blob)) = line.split_once('\t') else {
531            continue;
532        };
533        if !(pane.starts_with('%') && pane[1..].bytes().all(|b| b.is_ascii_digit())) {
534            continue;
535        }
536        let id = format!("{}:{}", host, pane);
537        let f = dir.join(index::key_for(&id));
538        let _ = std::fs::write(&f, format!("idx 0 {} {} -\n{}\n", n, id, blob));
539    }
540}
541
542/// Every discovered host's index, on its own longer TTL.
543///
544/// A minute rather than the three seconds a LIST is worth: what was said in a
545/// session does not go stale on the timescale a pane's state does.
546pub fn index_remote(panes: &[(String, String)]) {
547    if !enabled() || !env::on("TAIMUX_SEARCH_REMOTE") {
548        return;
549    }
550    let dir = index::index_dir();
551    let ttl = env::num("TAIMUX_SEARCH_REMOTE_TTL", 60) as i64;
552    let n = now();
553    for h in hosts_cached(&cache_dir(), panes) {
554        let stampfile = dir.join(format!(".{}.stamp", h));
555        let last: i64 = std::fs::read_to_string(&stampfile)
556            .ok()
557            .and_then(|s| s.trim().parse().ok())
558            .unwrap_or(0);
559        if n - last < ttl {
560            continue;
561        }
562        let _ = std::fs::create_dir_all(&dir);
563        let _ = std::fs::write(&stampfile, format!("{}\n", n));
564        index_fetch(&h, &dir);
565    }
566}
567
568/// A row's pane id is either a local `%17` or a remote `<host>:%6`.
569pub fn pane_host(id: &str) -> Option<&str> {
570    if id.is_empty() || id.starts_with('%') {
571        return None;
572    }
573    id.split_once(':').map(|(h, _)| h)
574}
575
576pub fn pane_local(id: &str) -> &str {
577    if id.is_empty() || id.starts_with('%') {
578        return id;
579    }
580    id.split_once(':').map(|(_, p)| p).unwrap_or(id)
581}
582
583/// Drive another host's tmux AS A PEER, then bring the local client to its ssh
584/// pane.
585///
586/// **In that order**, so the pane is already showing the right window by the time
587/// the local client arrives in it. The other way round, a jump lands on whatever
588/// that host was last looking at and corrects itself a moment later.
589pub fn switch_remote(host: &str, lid: &str, local_pane: &str, zoom: bool) -> Result<(), String> {
590    if !lid.starts_with('%') {
591        return Err("not a pane id".into());
592    }
593    if local_pane.is_empty() {
594        return Err(format!("nothing is ssh'd into {} any more", host));
595    }
596    let mut remote = format!(
597        "tmux select-window -t {0} \\; select-pane -t {0} \\; switch-client -t {0}",
598        lid
599    );
600    if zoom {
601        // resize-pane -Z TOGGLES, so it is asked first, exactly as the local
602        // path does.
603        remote.push_str(&format!(
604            "\n[ \"$(tmux display-message -p -t {0} '#{{window_zoomed_flag}}')\" = 1 ] || tmux resize-pane -Z -t {0}",
605            lid
606        ));
607    }
608    let (rc, _) = ssh(host, &remote, env::num("TAIMUX_SSH_TIMEOUT", 4));
609    // A host that has gone quiet since the list was built still gets you taken
610    // to its pane: that is where the dead connection is, and where you would go
611    // to deal with it. Only the remote half is lost, and not silently.
612    let lost = rc != 0;
613    if taimux_core::tmux::switch_local(local_pane, zoom) {
614        if lost {
615            Err(format!("{} did not answer, going to its pane anyway", host))
616        } else {
617            Ok(())
618        }
619    } else {
620        Err(format!("could not reach {}", local_pane))
621    }
622}
623
624/// pane id and tty for every pane on this server, which is what the discovery
625/// scan needs and what the caller already has.
626pub fn tmux_pane_ttys() -> Vec<(String, String)> {
627    taimux_core::tmux::ask_raw(&["list-panes", "-a", "-F", "#{pane_id}\t#{pane_tty}"])
628        .unwrap_or_default()
629        .lines()
630        .filter_map(|l| l.split_once('\t'))
631        .map(|(a, b)| (a.to_string(), b.to_string()))
632        .collect()
633}
634
635/// The whole list: this server's agent panes plus every discovered host's.
636pub fn all_panes(local: &str, panes: &[(String, String)]) -> String {
637    format!("{}{}", local, rows(panes))
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn an_ssh_tmux_argv_names_its_host() {
646        assert_eq!(
647            ssh_host("ssh ha -t tmux new-session -As main").as_deref(),
648            Some("ha")
649        );
650        assert_eq!(
651            ssh_host("/usr/bin/ssh laptop-two -t tmux attach").as_deref(),
652            Some("laptop-two")
653        );
654    }
655
656    /// ssh is a TOKEN, not argv[0]: a `#!` wrapper named ssh runs as
657    /// `bash /path/to/ssh host …`, and requiring argv[0] found no hosts at all
658    /// on such a machine.
659    #[test]
660    fn ssh_is_found_when_it_is_not_argv_zero() {
661        assert_eq!(
662            ssh_host("bash /home/p/bin/ssh ha -t tmux attach").as_deref(),
663            Some("ha")
664        );
665    }
666
667    /// A later `tmux` is required, or `ssh host journalctl -f` and a plain remote
668    /// login shell would both look like a window onto another server.
669    #[test]
670    fn without_a_later_tmux_it_is_not_a_window_onto_a_server() {
671        assert_eq!(ssh_host("ssh ha journalctl -f"), None);
672        assert_eq!(ssh_host("ssh ha"), None);
673        assert_eq!(ssh_host("tmux attach"), None);
674        assert_eq!(ssh_host(""), None);
675    }
676
677    /// An option that takes a separate value must not have that value read as
678    /// the host.
679    #[test]
680    fn an_options_value_is_not_mistaken_for_a_host() {
681        assert_eq!(
682            ssh_host("ssh -p 2222 ha -t tmux attach").as_deref(),
683            Some("ha")
684        );
685        assert_eq!(
686            ssh_host("ssh -i /k/id_ed25519 -o Foo=bar ha tmux attach").as_deref(),
687            Some("ha")
688        );
689        // a flag with no separate value is skipped without eating the host
690        assert_eq!(ssh_host("ssh -4 -A ha tmux attach").as_deref(), Some("ha"));
691    }
692
693    /// The name becomes a cache filename and an ssh argument, and there is no
694    /// such thing as a hostname needing a quote.
695    #[test]
696    fn an_unexpected_character_in_a_host_drops_it() {
697        assert_eq!(ssh_host("ssh ha;rm -rf / tmux attach"), None);
698        assert_eq!(ssh_host("ssh ../etc tmux attach"), None);
699        assert_eq!(ssh_host("ssh $HOST tmux attach"), None);
700        // …but the ordinary shapes are fine
701        assert_eq!(
702            ssh_host("ssh user@ha.example.com tmux attach").as_deref(),
703            Some("user@ha.example.com")
704        );
705    }
706
707    #[test]
708    fn a_composite_id_splits_into_host_and_pane() {
709        assert_eq!(pane_host("ha:%6"), Some("ha"));
710        assert_eq!(pane_local("ha:%6"), "%6");
711        assert_eq!(pane_host("%17"), None);
712        assert_eq!(pane_local("%17"), "%17");
713        assert_eq!(pane_host(""), None);
714        assert_eq!(pane_local(""), "");
715        // the note row a host that stopped answering keeps
716        assert_eq!(pane_host("ha:!"), Some("ha"));
717        assert_eq!(pane_local("ha:!"), "!");
718    }
719
720    #[test]
721    fn an_ssh_failure_says_what_kind() {
722        assert_eq!(ssh_why(127), "no taimux on that host");
723        assert_eq!(ssh_why(124), "timed out");
724        assert_eq!(ssh_why(137), "timed out");
725        assert_eq!(ssh_why(255), "unreachable");
726        assert_eq!(ssh_why(3), "ssh failed");
727    }
728
729    fn fixture(tag: &str) -> PathBuf {
730        let d = std::env::temp_dir().join(format!("jmrem{}{}", std::process::id(), tag));
731        let _ = std::fs::remove_dir_all(&d);
732        std::fs::create_dir_all(&d).unwrap();
733        d
734    }
735
736    #[test]
737    fn a_cached_reply_becomes_host_prefixed_rows() {
738        let d = fixture("r");
739        std::fs::write(
740            d.join("ha"),
741            "ok 100\n%6\tmain:1.7\t/w\tclaude\t2.1.1\trun\t-\tover there\n",
742        )
743        .unwrap();
744        let out = render("ha", &d);
745        assert!(out.starts_with("ha:%6\tmain:1.7\t"));
746        assert_eq!(out.lines().count(), 1);
747        let _ = std::fs::remove_dir_all(&d);
748    }
749
750    /// A host that has answered before and then goes quiet keeps ONE row saying
751    /// so. One that never answered gets none: it is simply not running taimux,
752    /// which is not an outage.
753    #[test]
754    fn only_a_host_that_has_answered_before_keeps_a_row() {
755        let d = fixture("e");
756        std::fs::write(d.join("ha"), "err 100 unreachable\n").unwrap();
757        assert_eq!(render("ha", &d), "");
758        std::fs::write(d.join("ha.seen"), "").unwrap();
759        let out = render("ha", &d);
760        assert!(out.starts_with("ha:!\tha\t"));
761        assert!(out.contains("unreachable"));
762        let _ = std::fs::remove_dir_all(&d);
763    }
764
765    /// An older taimux over there answers in another shape. Say so on one row
766    /// rather than dropping its sessions silently.
767    #[test]
768    fn a_reply_in_another_format_says_so_once() {
769        let d = fixture("f");
770        std::fs::write(
771            d.join("ha"),
772            "ok 100\n%6\ttoo\tfew\tfields\n%7\talso\tshort\n",
773        )
774        .unwrap();
775        let out = render("ha", &d);
776        assert_eq!(out.lines().count(), 1);
777        assert!(out.contains("2 row(s) dropped"));
778        let _ = std::fs::remove_dir_all(&d);
779    }
780
781    #[test]
782    fn a_file_with_no_header_renders_nothing() {
783        let d = fixture("h");
784        assert_eq!(render("ha", &d), ""); // no file at all
785        std::fs::write(d.join("ha"), "").unwrap();
786        assert_eq!(render("ha", &d), "");
787        let _ = std::fs::remove_dir_all(&d);
788    }
789
790    /// The header is unconditional, so the file is never empty and the write is
791    /// never mistaken for a failure. bash tested the last command in a group,
792    /// which was the "any hosts?" guard, so every server with nothing to
793    /// federate re-ran the scan forever and left temp files behind.
794    #[test]
795    fn the_host_cache_is_written_even_with_no_hosts() {
796        // TMUX is process-global and the harness runs threads: two tests
797        // setting it at once read each other's key. Same lock as everywhere else.
798        let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
799        let d = fixture("c");
800        std::env::set_var("TMUX", "/tmp/sock,1,0");
801        let hosts = hosts_cached(&d, &[]);
802        assert!(hosts.is_empty());
803        let f = hosts_file(&d);
804        assert!(f.exists(), "no cache file was written");
805        assert!(std::fs::read_to_string(&f).unwrap().starts_with("hosts "));
806        // and nothing was left behind
807        let left: Vec<String> = std::fs::read_dir(&d)
808            .unwrap()
809            .flatten()
810            .map(|e| e.file_name().to_string_lossy().into_owned())
811            .filter(|n| n.contains(".t"))
812            .collect();
813        assert!(left.is_empty(), "temp files left: {:?}", left);
814        std::env::remove_var("TMUX");
815        let _ = std::fs::remove_dir_all(&d);
816    }
817
818    /// Keyed by tmux SERVER: the list comes off that server's panes, and a second
819    /// server on the same box has entirely different ones.
820    #[test]
821    fn the_host_cache_is_keyed_by_server() {
822        // TMUX is process-global and the harness runs threads: two tests
823        // setting it at once read each other's key. Same lock as everywhere else.
824        let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
825        let d = fixture("k");
826        std::env::set_var("TMUX", "/tmp/one,1,0");
827        let a = hosts_file(&d);
828        std::env::set_var("TMUX", "/tmp/two,1,0");
829        let b = hosts_file(&d);
830        assert_ne!(a, b);
831        std::env::remove_var("TMUX");
832        let _ = std::fs::remove_dir_all(&d);
833    }
834
835    /// The time limit is the ONLY way out of a popup whose helper wedges: a
836    /// child that swallows ctrl-c cannot be escaped from, because the parent
837    /// waits for it either way. So this has to actually kill.
838    #[test]
839    fn a_wedged_child_is_killed_rather_than_waited_for() {
840        let mut c = Command::new("sleep");
841        c.arg("30");
842        let started = Instant::now();
843        let (rc, out) = bounded(1, c);
844
845        // `bounded` answers 255 for three different things: the spawn failed,
846        // the child was signalled rather than exiting, and `try_wait` errored.
847        // None of them is what this test is about, and all three come back
848        // IMMEDIATELY, where the path under test cannot return before its
849        // deadline. Under the fork pressure of the whole suite running in
850        // parallel that happens about once in twelve runs; alone, never in
851        // sixty. Conflating them in production is right (a failed ssh really is
852        // "unreachable"), so the telling apart belongs here.
853        if rc == 255 && started.elapsed() < Duration::from_millis(500) {
854            eprintln!(
855                "no child was spawned to wedge, skipping: rc={} in {:?}",
856                rc,
857                started.elapsed()
858            );
859            return;
860        }
861
862        assert_eq!(
863            rc,
864            124,
865            "not the status `timeout` uses; came back in {:?}",
866            started.elapsed()
867        );
868        assert!(out.is_empty());
869        assert!(
870            started.elapsed() < Duration::from_secs(5),
871            "waited {:?} for a 1 s limit",
872            started.elapsed()
873        );
874    }
875
876    #[test]
877    fn a_prompt_child_runs_to_completion_and_its_output_comes_back() {
878        let mut c = Command::new("printf");
879        c.arg("hi");
880        assert_eq!(bounded(5, c), (0, "hi".to_string()));
881        // …and a non-zero status is reported as its own, not as a timeout
882        let c = Command::new("false");
883        assert_eq!(bounded(5, c).0, 1);
884        // a program that is not there is unreachable-shaped rather than a panic
885        let c = Command::new("no-such-program-at-all");
886        assert_eq!(bounded(5, c).0, 255);
887    }
888
889    /// The TTL split, without an ssh anywhere near it. An answer is worth three
890    /// seconds and a FAILURE sixty, because a sleeping host must not be
891    /// re-probed on every tick while a live one must not go stale.
892    #[test]
893    fn a_fresh_answer_is_left_alone_and_a_stale_one_is_refreshed() {
894        let d = fixture("q");
895        std::fs::write(d.join("fresh"), "ok 1000\n").unwrap();
896        std::fs::write(d.join("stale"), "ok 900\n").unwrap();
897        std::fs::write(d.join("dead"), "err 995 unreachable\n").unwrap();
898        std::fs::write(d.join("junk"), "nonsense\n").unwrap();
899        let hosts: Vec<String> = ["fresh", "stale", "dead", "junk", "never"]
900            .iter()
901            .map(|s| s.to_string())
902            .collect();
903        let (cold, refresh) = classify(&hosts, &d, 1001);
904        // never asked, and a header nobody recognises: both COLD, because
905        // serving them from cache would mean serving nothing
906        assert_eq!(cold, vec!["junk", "never"]);
907        // 101 s past a 3 s answer, but only 6 s past a 60 s failure
908        assert_eq!(refresh, vec!["stale"]);
909        let _ = std::fs::remove_dir_all(&d);
910    }
911
912    /// The preamble is what runs on the FAR side, and it has three jobs: find a
913    /// taimux that a non-interactive ssh's PATH does not reach, fall back to a
914    /// plugin checkout, and exit 127 when there is none so "no taimux over
915    /// there" is told apart from "unreachable".
916    #[test]
917    fn the_remote_preamble_finds_a_taimux_or_exits_127() {
918        let home = fixture("p");
919        let run = || -> (i32, String) {
920            let out = Command::new("sh")
921                .args(["-c", REMOTE_TAIMUX])
922                .arg("list")
923                .env("HOME", &home)
924                .env("PATH", "/usr/bin:/bin")
925                .output()
926                .expect("sh");
927            (
928                out.status.code().unwrap_or(-1),
929                String::from_utf8_lossy(&out.stdout).trim().to_string(),
930            )
931        };
932        // nothing installed at all
933        assert_eq!(run().0, 127);
934
935        // a plugin checkout is enough
936        let plug = home.join(".tmux/plugins/taimux-9f2c1b");
937        std::fs::create_dir_all(&plug).unwrap();
938        std::fs::write(
939            plug.join("taimux"),
940            "#!/bin/sh\necho answered-from-the-plugin-copy\n",
941        )
942        .unwrap();
943        set_exec(&plug.join("taimux"));
944        assert_eq!(run(), (0, "answered-from-the-plugin-copy".into()));
945
946        // …and a real install still outranks it
947        let bin = home.join(".local/bin");
948        std::fs::create_dir_all(&bin).unwrap();
949        std::fs::write(
950            bin.join("taimux"),
951            "#!/bin/sh\necho answered-from-the-real-install\n",
952        )
953        .unwrap();
954        set_exec(&bin.join("taimux"));
955        assert_eq!(run(), (0, "answered-from-the-real-install".into()));
956        let _ = std::fs::remove_dir_all(&home);
957    }
958
959    fn set_exec(p: &Path) {
960        use std::os::unix::fs::PermissionsExt;
961        let mut m = std::fs::metadata(p).unwrap().permissions();
962        m.set_mode(0o755);
963        std::fs::set_permissions(p, m).unwrap();
964    }
965
966    /// A pane that is not an ssh window resolves to itself, and the cheap
967    /// pre-filter is what stops a process scan on every picker open.
968    #[test]
969    fn resolve_cur_leaves_an_ordinary_pane_alone() {
970        assert_eq!(resolve_cur("%7", &[], "bash"), "%7");
971        assert_eq!(resolve_cur("%7", &[], "ssh"), "%7"); // ssh, but no host found
972        assert_eq!(resolve_cur("dead:/x", &[], "ssh"), "dead:/x");
973    }
974}