feat(ui): say how far away a remote pane's shell is

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
This commit is contained in:
l0ng-ai
2026-09-10 18:06:21 +08:00
parent 2ef652436b
commit ac36d4a4fa
8 changed files with 232 additions and 6 deletions
+37 -2
View File
@@ -938,6 +938,11 @@ struct ClientInner {
blobs: Mutex<HashMap<u64, Vec<u8>>>,
connected: AtomicBool,
last_inbound: Mutex<Instant>,
/// 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<Arc<dyn LinkShutdown>>,
reader_done: Mutex<bool>,
@@ -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<Duration> {
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<ReplyOk> {
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,
+9
View File
@@ -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<std::time::Duration> {
None
}
fn separator(&self) -> char;
fn join(&self, dir: &Path, name: &str) -> PathBuf {
+85
View File
@@ -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<std::time::Duration> {
// 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<crate::daemon::protocol::PaneProcs> {
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 {
+1
View File
@@ -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",
+1
View File
@@ -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 => {
"このペインが何をリッスンしているか確認できませんでした。"
+1
View File
@@ -788,6 +788,7 @@ l10n_keys! {
PanelPortsUnsupported,
PanelPortsProbeFailed,
PanelPortsRestricted,
PanelLatency,
PortAutoForwarded,
PanelCwd,
PanelShell,
+1
View File
@@ -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 => "这里有以其他用户身份运行的进程,看不到它们的端口。",
+97 -4
View File
@@ -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<std::time::Duration>,
/// 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<crate::ui::host_ops::HostId>,
/// 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