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 /// `bounded`, for a child that is supposed to start: `None` when it never
877 /// did, rather than the answer a failed fork is indistinguishable from.
878 ///
879 /// The same conflation the test above steps around, met from the other side.
880 /// There a spawn failure could be told apart by the clock, since the path
881 /// under test cannot return before its deadline; here the child is a `printf`
882 /// that returns at once either way, so the only thing left is that a fork
883 /// this machine refused is **transient**. It was refused for want of a slot
884 /// under the suite's own parallelism, not because anything about the child is
885 /// wrong, so it is worth simply asking again: five tries 50ms apart against a
886 /// failure rate the neighbour measures at about one run in twelve.
887 ///
888 /// Not fixed in `bounded` itself. Answering 255 for a child that never
889 /// started is right for its caller, where a failed ssh and an unreachable
890 /// host are the same thing, and a status invented for the benefit of a test
891 /// would be a different tool under the picker.
892 fn spawned(secs: u64, make: impl Fn() -> Command) -> Option<(i32, String)> {
893 for _ in 0..5 {
894 let got = bounded(secs, make());
895 if got != (255, String::new()) {
896 return Some(got);
897 }
898 std::thread::sleep(Duration::from_millis(50));
899 }
900 None
901 }
902
903 /// Said out loud, because a check that quietly does not run is worse than
904 /// one that fails.
905 fn no_fork() {
906 eprintln!("bounded: no child could be spawned in five tries, skipping");
907 }
908
909 #[test]
910 fn a_prompt_child_runs_to_completion_and_its_output_comes_back() {
911 let Some(got) = spawned(5, || {
912 let mut c = Command::new("printf");
913 c.arg("hi");
914 c
915 }) else {
916 return no_fork();
917 };
918 assert_eq!(got, (0, "hi".to_string()));
919 // …and a non-zero status is reported as its own, not as a timeout
920 let Some(got) = spawned(5, || Command::new("false")) else {
921 return no_fork();
922 };
923 assert_eq!(got.0, 1);
924 // A program that is not there is unreachable-shaped rather than a panic,
925 // and this one asks `bounded` directly: the shape `spawned` retries IS
926 // the answer here, so going through it would be waiting out five
927 // deliberate failures to arrive at the same 255.
928 let c = Command::new("no-such-program-at-all");
929 assert_eq!(bounded(5, c).0, 255);
930 }
931
932 /// The other half of `spawned`'s contract, and the only thing that ever runs
933 /// its give-up arm on a machine that can fork: five failures in a row are
934 /// reported as no answer, never as the 255 they are made of.
935 #[test]
936 fn a_child_that_never_starts_is_no_answer_at_all() {
937 assert_eq!(spawned(5, || Command::new("no-such-program-at-all")), None);
938 }
939
940 /// The TTL split, without an ssh anywhere near it. An answer is worth three
941 /// seconds and a FAILURE sixty, because a sleeping host must not be
942 /// re-probed on every tick while a live one must not go stale.
943 #[test]
944 fn a_fresh_answer_is_left_alone_and_a_stale_one_is_refreshed() {
945 let d = fixture("q");
946 std::fs::write(d.join("fresh"), "ok 1000\n").unwrap();
947 std::fs::write(d.join("stale"), "ok 900\n").unwrap();
948 std::fs::write(d.join("dead"), "err 995 unreachable\n").unwrap();
949 std::fs::write(d.join("junk"), "nonsense\n").unwrap();
950 let hosts: Vec<String> = ["fresh", "stale", "dead", "junk", "never"]
951 .iter()
952 .map(|s| s.to_string())
953 .collect();
954 let (cold, refresh) = classify(&hosts, &d, 1001);
955 // never asked, and a header nobody recognises: both COLD, because
956 // serving them from cache would mean serving nothing
957 assert_eq!(cold, vec!["junk", "never"]);
958 // 101 s past a 3 s answer, but only 6 s past a 60 s failure
959 assert_eq!(refresh, vec!["stale"]);
960 let _ = std::fs::remove_dir_all(&d);
961 }
962
963 /// The preamble is what runs on the FAR side, and it has three jobs: find a
964 /// taimux that a non-interactive ssh's PATH does not reach, fall back to a
965 /// plugin checkout, and exit 127 when there is none so "no taimux over
966 /// there" is told apart from "unreachable".
967 #[test]
968 fn the_remote_preamble_finds_a_taimux_or_exits_127() {
969 let home = fixture("p");
970 let run = || -> (i32, String) {
971 let out = Command::new("sh")
972 .args(["-c", REMOTE_TAIMUX])
973 .arg("list")
974 .env("HOME", &home)
975 .env("PATH", "/usr/bin:/bin")
976 .output()
977 .expect("sh");
978 (
979 out.status.code().unwrap_or(-1),
980 String::from_utf8_lossy(&out.stdout).trim().to_string(),
981 )
982 };
983 // nothing installed at all
984 assert_eq!(run().0, 127);
985
986 // a plugin checkout is enough
987 let plug = home.join(".tmux/plugins/taimux-9f2c1b");
988 std::fs::create_dir_all(&plug).unwrap();
989 std::fs::write(
990 plug.join("taimux"),
991 "#!/bin/sh\necho answered-from-the-plugin-copy\n",
992 )
993 .unwrap();
994 set_exec(&plug.join("taimux"));
995 assert_eq!(run(), (0, "answered-from-the-plugin-copy".into()));
996
997 // …and a real install still outranks it
998 let bin = home.join(".local/bin");
999 std::fs::create_dir_all(&bin).unwrap();
1000 std::fs::write(
1001 bin.join("taimux"),
1002 "#!/bin/sh\necho answered-from-the-real-install\n",
1003 )
1004 .unwrap();
1005 set_exec(&bin.join("taimux"));
1006 assert_eq!(run(), (0, "answered-from-the-real-install".into()));
1007 let _ = std::fs::remove_dir_all(&home);
1008 }
1009
1010 fn set_exec(p: &Path) {
1011 use std::os::unix::fs::PermissionsExt;
1012 let mut m = std::fs::metadata(p).unwrap().permissions();
1013 m.set_mode(0o755);
1014 std::fs::set_permissions(p, m).unwrap();
1015 }
1016
1017 /// A pane that is not an ssh window resolves to itself, and the cheap
1018 /// pre-filter is what stops a process scan on every picker open.
1019 #[test]
1020 fn resolve_cur_leaves_an_ordinary_pane_alone() {
1021 assert_eq!(resolve_cur("%7", &[], "bash"), "%7");
1022 assert_eq!(resolve_cur("%7", &[], "ssh"), "%7"); // ssh, but no host found
1023 assert_eq!(resolve_cur("dead:/x", &[], "ssh"), "dead:/x");
1024 }
1025}