From ac36d4a4fab6d89d5ffbac968e0ff2ef6188f4ae Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:05:58 +0800 Subject: [PATCH] feat(ui): say how far away a remote pane's shell is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Session table named the machine a pane's shell was on but never the distance to it. A remote workspace gone slow looked exactly like one that had not, and the only way to tell them apart was to leave tty7 and ping the box by hand — with nothing to say whether tty7's own link was the one that was slow. Time the control link's `Ping` and put the last measurement on a `latency` row, drawn only where there is a network between here and the shell. Every ping that comes back feeds it, the keepalive's included, so a link being kept alive already carries a number before anyone asks for one. Nothing else is timed: every other request does work on the far side, so its round trip measures that work rather than the link, and a `ReadFile` of a large file would read as a network seconds slow. The poll rides the Info panel's existing process-and-port round, and only while the panel is open — that round also runs with the panel shut, watching for ports to forward, and a round trip for a row nobody can see is the far end's time spent on nothing. A link that drops keeps its last measurement rather than blanking: a dropped link is exactly when someone is reading the row to work out why a pane stopped answering. Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt --- crates/tty7-core/src/daemon/control.rs | 39 +++++++++- crates/tty7-core/src/host/mod.rs | 9 +++ crates/tty7-core/src/host/remote.rs | 85 +++++++++++++++++++++ src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + src/ui/right_panel.rs | 101 ++++++++++++++++++++++++- 8 files changed, 232 insertions(+), 6 deletions(-) diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 72bb557f..d8b01dd4 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -938,6 +938,11 @@ struct ClientInner { blobs: Mutex>>, connected: AtomicBool, last_inbound: Mutex, + /// How long the last `Ping` took to come back, in microseconds; zero until + /// one has. Only `Ping` is timed: every other request does work on the far + /// side, so its round trip measures that work and not the link, and a + /// `ReadFile` of a large file would report the network as seconds slow. + last_rtt_us: AtomicU64, hello: ControlHelloOk, shutdown: Option>, reader_done: Mutex, @@ -1016,6 +1021,7 @@ impl ControlClient { blobs: Mutex::new(HashMap::new()), connected: AtomicBool::new(true), last_inbound: Mutex::new(Instant::now()), + last_rtt_us: AtomicU64::new(0), hello: ok, shutdown, reader_done: Mutex::new(false), @@ -1059,6 +1065,19 @@ impl ControlClient { .unwrap_or_default() } + /// The last measured round trip to the peer, or `None` on a link nothing + /// has pinged yet. + /// + /// Fed by every [`ControlRequest::Ping`] that comes back — the keepalive's + /// as well as any a caller sends itself — so a link that is being kept + /// alive already carries a number without anyone asking for one. + pub fn last_rtt(&self) -> Option { + match self.inner.last_rtt_us.load(Ordering::Relaxed) { + 0 => None, + us => Some(Duration::from_micros(us)), + } + } + pub fn call(&self, req: ControlRequest) -> io::Result { self.call_full(req, &[]).map(|r| r.reply) } @@ -1086,6 +1105,9 @@ impl ControlClient { } let req_id = self.inner.next_req_id.fetch_add(1, Ordering::Relaxed); + // Read off before `req` is moved into the message below. + let timed = matches!(req, ControlRequest::Ping); + let sent_at = Instant::now(); log::debug!(target: "tty7::control", "#{req_id} {req:?}"); let (tx, rx) = sync_channel(1); self.inner.pending()?.insert(req_id, tx); @@ -1108,9 +1130,21 @@ impl ControlClient { match rx.recv_timeout(deadline) { Ok(reply) => { let blob = self.inner.take_blob(req_id); - reply + let out = reply .into_result() - .map(|reply| ControlResponse { reply, blob }) + .map(|reply| ControlResponse { reply, blob }); + // Only a ping that actually came back. A timeout leaves the + // last good number in place rather than recording the deadline + // as the link's latency, and a refusal measures the peer's + // opinion of the request rather than the distance to it. + if timed && out.is_ok() { + let us = sent_at.elapsed().as_micros().min(u128::from(u64::MAX)) as u64; + // Zero means "never measured", so a sub-microsecond round + // trip on a loopback link rounds up rather than reading as + // no measurement at all. + self.inner.last_rtt_us.store(us.max(1), Ordering::Relaxed); + } + out } Err(RecvTimeoutError::Timeout) => { self.inner.forget(req_id); @@ -1343,6 +1377,7 @@ mod tests { blobs: Mutex::new(HashMap::new()), connected: AtomicBool::new(true), last_inbound: Mutex::new(Instant::now()), + last_rtt_us: AtomicU64::new(0), hello: ControlHelloOk { control_version: CONTROL_VERSION, protocol_version: 0, diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index 6bd0a7de..28291a03 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -173,6 +173,15 @@ pub trait WatchHandle: Send + Sync { pub trait Host: Send + Sync + 'static { fn id(&self) -> HostId; + /// How far away this host is: the round trip to it, measured now. + /// + /// `None` from a host with no link to measure — the local one, whose + /// "peer" is this process's own daemon over a Unix socket — and from a + /// remote one whose link is down or has not answered a ping yet. + fn link_rtt(&self) -> Option { + None + } + fn separator(&self) -> char; fn join(&self, dir: &Path, name: &str) -> PathBuf { diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 7183abe8..e3803b3f 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -257,6 +257,22 @@ impl Host for RemoteHost { /// process trees. A peer that does not announce the feature is not asked: /// it would answer `Err` and the caller cannot tell that apart from a pane /// serving nothing. + fn link_rtt(&self) -> Option { + // A ping of our own rather than whatever the keepalive last left + // behind: that one only fires on an idle link, and a link being polled + // for this is by definition not idle, so its number would age out of + // date exactly while someone is watching it. + if self.client.is_connected() + && let Err(e) = self.client.ping() + { + // The last good measurement below still stands. A link that has + // just gone down reports the distance it had while it was up, + // which is better than a blank until something notices it is gone. + log::debug!("could not ping {:?}: {e}", self.id); + } + self.client.last_rtt() + } + fn pane_procs(&self, pane_id: u64) -> Option { if !self .peer() @@ -1051,6 +1067,75 @@ mod tests { .unwrap() } + #[test] + fn link_rtt_pings_and_reports_what_came_back() { + let (host, seen) = host_with_peer('/', |req| match req { + ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + other => panic!("unexpected request {other:?}"), + }); + + assert!( + host.link_rtt().is_some(), + "a ping that came back is a measurement" + ); + assert_eq!(seen.recv().unwrap(), ControlRequest::Ping); + assert_eq!( + seen.try_recv().ok(), + None, + "one row is worth one round trip and no more" + ); + } + + /// The latency row must mean the distance to the peer, not how long the + /// peer spent on whatever was asked of it. Timing every call would put a + /// `ReadFile` of a large file, or a `Git` that shells out, on that row and + /// read as a network gone seconds slow. + #[test] + fn only_a_ping_is_timed() { + let (host, _seen) = host_with_peer('/', |req| match req { + ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + ControlRequest::Exists { .. } => Some((ControlReply::Ok(ReplyOk::Bool(true)), vec![])), + other => panic!("unexpected request {other:?}"), + }); + + assert_eq!( + host.client().last_rtt(), + None, + "a link nothing has pinged has no measurement to report" + ); + assert!(host.exists(Path::new("/etc/hosts"))); + assert_eq!( + host.client().last_rtt(), + None, + "an ordinary call measures the peer's work, so it leaves the link's latency alone" + ); + host.client().ping().unwrap(); + assert!(host.client().last_rtt().is_some()); + } + + /// A link that has just gone down keeps the distance it had while it was + /// up. Blanking the row on the first failed ping would take the number + /// away at exactly the moment someone is looking at it to work out why the + /// pane has stopped responding. + #[test] + fn a_link_that_goes_down_keeps_its_last_measurement() { + let served = std::sync::atomic::AtomicBool::new(true); + let (host, _seen) = host_with_peer('/', move |req| match req { + ControlRequest::Ping => match served.swap(false, Ordering::Relaxed) { + true => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + // Hanging up rather than answering, which is what a peer whose + // machine went away looks like from here. + false => None, + }, + other => panic!("unexpected request {other:?}"), + }); + + let measured = host.link_rtt().expect("the first ping came back"); + assert_eq!(host.link_rtt(), Some(measured)); + assert!(!host.is_connected(), "the second ping took the link down"); + assert_eq!(host.link_rtt(), Some(measured)); + } + #[test] fn shells_come_from_the_peer() { let (host, seen) = host_with_peer('/', |req| match req { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4884935d..d2fd7cf9 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1070,6 +1070,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelPortsRestricted => { "Something here runs as another user, whose ports aren't visible." } + L10nKey::PanelLatency => "latency", L10nKey::PortAutoForwarded => "Remote :{port} is now http://localhost:{local}", L10nKey::PanelCwd => "cwd", L10nKey::PanelShell => "shell", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index d4e30d71..c4b3810e 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1129,6 +1129,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSessionSubtitle => "セッション", L10nKey::PanelProcessesSubtitle => "プロセス", L10nKey::PanelPortsSubtitle => "ポート", + L10nKey::PanelLatency => "遅延", L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。", L10nKey::PanelPortsProbeFailed => { "このペインが何をリッスンしているか確認できませんでした。" diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 627f7c2c..90f47ca6 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -788,6 +788,7 @@ l10n_keys! { PanelPortsUnsupported, PanelPortsProbeFailed, PanelPortsRestricted, + PanelLatency, PortAutoForwarded, PanelCwd, PanelShell, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 8b53c7a7..b3d2ddc3 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1020,6 +1020,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSessionSubtitle => "会话", L10nKey::PanelProcessesSubtitle => "进程", L10nKey::PanelPortsSubtitle => "端口", + L10nKey::PanelLatency => "延迟", L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。", L10nKey::PanelPortsProbeFailed => "没能查出这个窗格在监听什么。", L10nKey::PanelPortsRestricted => "这里有以其他用户身份运行的进程,看不到它们的端口。", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 43e21b74..3521a53f 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -167,6 +167,16 @@ pub(crate) struct RightPanelState { /// and "nobody could tell us" are different sentences and the panel has to /// say which one it means. pub(crate) procs_unsupported: bool, + /// The last round trip measured to the machine `procs_pane` lives on. + /// `None` before the first ping comes back. + pub(crate) link_rtt: Option, + /// Which host `link_rtt` was measured against, and — since only a remote + /// pane has one — whether the latency row is drawn at all. Held per host + /// rather than per pane so that moving between two panes of the same + /// machine keeps the number on screen: it belongs to the link the two + /// panes share, and blanking it per pane would empty the row for as long + /// as the next poll takes to cross the network. + pub(crate) link_host: Option, /// How `procs_pane`'s loopback ports can be reached from this machine. /// Read by the Ports list to decide what a click on a port does, and by /// the watch to decide whether it has to keep looking with the panel shut. @@ -251,6 +261,29 @@ enum InfoValue { }, } +/// The table convention for a cell with nothing in it. Needs no translating, +/// and is shorter to read than any of the sentences it stands in for. +const EMPTY: &str = "—"; + +/// A round trip, at the precision the number is worth reading to. +/// +/// Whole milliseconds up to a second: tenths of a millisecond on a link that +/// varies by whole ones is noise dressed as measurement. Past a second the +/// millisecond stops mattering and the second is the unit anyone would say it +/// in. +fn format_rtt(rtt: std::time::Duration) -> String { + let ms = rtt.as_secs_f64() * 1000.; + if ms < 1. { + // Loopback and a peer on the same LAN both land here. Rounding to + // "0 ms" would read as a failed measurement rather than a fast one. + return "<1 ms".to_string(); + } + if ms < 1000. { + return format!("{} ms", ms.round() as u64); + } + format!("{:.1} s", rtt.as_secs_f64()) +} + /// One label/value line of the Session section. struct InfoRow { label: &'static str, @@ -781,6 +814,23 @@ impl Tty7App { if let Some(ssh) = view.ssh_spec() { rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable()); } + // Only where there is a network between here and the shell. On + // a pane of this machine's own the row would be reporting the + // round trip to a Unix socket, which is a number with nothing + // to compare it against. + if self.right_panel.link_host.is_some() { + rows.push(InfoRow::text( + t(L10nKey::PanelLatency), + // A link whose first ping has not come back yet, + // rather than one measured at zero. The dash is the + // table's empty cell, the same one a clean working + // tree gets. + self.right_panel + .link_rtt + .map(format_rtt) + .unwrap_or_else(|| EMPTY.to_string()), + )); + } git = view.git_status(cx); } // Read off the same pane the rows above describe, rather than off @@ -916,7 +966,7 @@ impl Tty7App { this.child( div() .text_color(cx.theme().muted_foreground) - .child("—".to_string()), + .child(EMPTY.to_string()), ) }) .when(added > 0, |this| { @@ -1589,6 +1639,14 @@ impl Tty7App { let Some(pane_id) = pane_id else { return }; self.right_panel.procs_forwards = forwards.clone(); self.right_panel.procs_host = host.clone(); + // Per host, not per pane — see `link_host`. A pane of this machine's + // own has no host at all, which is what clears the section rather than + // leaving the last remote pane's numbers under a local one. + let link_host = host.as_ref().map(|h| h.id()); + if self.right_panel.link_host != link_host { + self.right_panel.link_host = link_host; + self.right_panel.link_rtt = None; + } if self.right_panel.procs_pane != Some(pane_id) { self.right_panel.procs_pane = Some(pane_id); self.right_panel.procs = None; @@ -1620,7 +1678,15 @@ impl Tty7App { ) { cx.spawn(async move |this, cx| { let route = forwards.clone(); - let (procs, managed) = cx + // Only while someone is looking. This poll also runs with the panel + // shut, watching for ports to forward, and a round trip per round + // for a row nobody can see is the far end's time spent on nothing. + let want_link = this + .read_with(cx, |app, _| { + app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info + }) + .unwrap_or(false); + let (procs, managed, link) = cx .background_executor() .spawn(async move { // A remote workspace's pane runs on the peer, so the peer @@ -1637,7 +1703,11 @@ impl Tty7App { None => Some(crate::terminal::RemoteTerminal::query_procs(pane_id)), }; let managed = route.map(|r| r.list()).unwrap_or_default(); - (procs, managed) + let link = match (want_link, &host) { + (true, Some(host)) => host.link_rtt(), + _ => None, + }; + (procs, managed, link) }) .await; let keep_polling = this @@ -1655,6 +1725,13 @@ impl Tty7App { if forwards.is_some() { app.loopback_panel.managed = managed; } + // Only when this round actually asked. A round that did not + // leaves the last answer in place, so reopening the panel + // shows the number it was closed on rather than a dash + // until the next poll lands. + if want_link { + app.right_panel.link_rtt = link; + } cx.notify(); let wanted = app.procs_wanted(); if !wanted { @@ -1813,7 +1890,7 @@ fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> Strin #[cfg(test)] mod tests { - use super::{InfoRow, InfoValue, forwards_port}; + use super::{InfoRow, InfoValue, format_rtt, forwards_port}; use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind}; fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward { @@ -1919,6 +1996,22 @@ mod tests { ); } + #[test] + fn a_round_trip_is_read_at_the_precision_it_is_worth() { + use std::time::Duration; + // A peer on the same machine or the same LAN. "0 ms" would read as a + // measurement that failed rather than one that was fast. + assert_eq!(format_rtt(Duration::from_micros(120)), "<1 ms"); + assert_eq!(format_rtt(Duration::from_micros(999)), "<1 ms"); + assert_eq!(format_rtt(Duration::from_millis(1)), "1 ms"); + assert_eq!(format_rtt(Duration::from_micros(23_400)), "23 ms"); + assert_eq!(format_rtt(Duration::from_millis(999)), "999 ms"); + // Past a second the millisecond has stopped carrying information, and + // the second is the unit anyone would say the number in. + assert_eq!(format_rtt(Duration::from_millis(1_450)), "1.4 s"); + assert_eq!(format_rtt(Duration::from_secs(4)), "4.0 s"); + } + #[test] fn copyable_takes_the_text_the_row_shows_and_nothing_else() { // `copyable()` reads the value it was given; rows built with an