mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => {
|
||||
"このペインが何をリッスンしているか確認できませんでした。"
|
||||
|
||||
@@ -788,6 +788,7 @@ l10n_keys! {
|
||||
PanelPortsUnsupported,
|
||||
PanelPortsProbeFailed,
|
||||
PanelPortsRestricted,
|
||||
PanelLatency,
|
||||
PortAutoForwarded,
|
||||
PanelCwd,
|
||||
PanelShell,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user