use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail, ensure};
use ssh_browser::sftp::wire::{
Attrs, CLOSE, DATA, Dec, Enc, FXF_READ, HANDLE, NAME, OPEN, OPENDIR, READ, READDIR, REALPATH,
STATUS,
};
use ssh_browser::sftp::{Sftp, transport};
use tokio::io::{BufReader, BufWriter};
use tokio::process::{ChildStdin, ChildStdout};
type Session = Sftp<BufWriter<ChildStdin>, BufReader<ChildStdout>>;
const BATCH_SIZES: [usize; 4] = [1, 8, 20, 40];
const READ_LEN: u32 = 32 * 1024;
const TAU_REPS: usize = 7;
const PIPELINE_MARGIN: f64 = 4.0;
#[tokio::main]
async fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let host = args
.next()
.context("usage: measure-roundtrips <ssh-host> [remote-dir]")?;
let dir = args.next().unwrap_or_else(|| "/usr/include".to_string());
let (_child, w, r) = transport::open(&host)?;
let mut s = Sftp::handshake(w, r).await?;
println!("host {host} sftp v{}", s.version());
let tau = measure_tau(&mut s).await?;
println!("tau (one round trip) = {:.1} ms", ms(tau));
let entries = list(&mut s, &dir).await?;
let files: Vec<String> = entries
.iter()
.filter(|(name, a)| {
!a.is_dir() && !a.is_symlink() && a.size.unwrap_or(0) > 0 && name != "." && name != ".."
})
.map(|(name, _)| format!("{}/{}", dir.trim_end_matches('/'), name))
.collect();
ensure!(
!files.is_empty(),
"no regular non-empty files in {dir}; pass a different remote-dir"
);
println!(
"{} entries in {dir}, {} usable files",
entries.len(),
files.len()
);
println!();
let mut largest = 0usize;
let mut largest_open = 0.0f64;
let mut largest_read = 0.0f64;
let mut prev_read = 0.0f64;
let mut last_read = 0.0f64;
for n in BATCH_SIZES {
if n > files.len() {
continue;
}
let batch = &files[..n];
let (t_open, handles) = batch_open(&mut s, batch).await?;
let (t_read, bytes) = batch_read(&mut s, &handles).await?;
batch_close(&mut s, &handles).await?;
let open_tau = t_open.as_secs_f64() / tau.as_secs_f64();
let read_tau = t_read.as_secs_f64() / tau.as_secs_f64();
println!(
"n={n:<3} open {:>7.1} ms ({open_tau:>5.2} tau) read {:>7.1} ms ({read_tau:>5.2} tau) {bytes} B",
ms(t_open),
ms(t_read)
);
largest = n;
largest_open = open_tau;
largest_read = read_tau;
if n > 1 {
prev_read = last_read;
}
last_read = read_tau;
}
println!();
ensure!(
largest > 1,
"only one usable file; cannot distinguish pipelined from serial"
);
if prev_read > 0.0 {
let growth = largest_read / prev_read;
println!(
"reads {largest_read:.2} tau at n={largest}, {growth:.2}x the previous batch (serial would be about 2x)"
);
}
let serial = largest as f64;
if largest_open < serial / PIPELINE_MARGIN {
println!(
"VERDICT pipelined: opens cost {largest_open:.2} tau at n={largest} (serial would cost about {serial:.0})"
);
Ok(())
} else {
bail!(
"VERDICT serial: opens cost {largest_open:.2} tau at n={largest}, near the serial cost {serial:.0}. Invariant 1 (O(1) round trips per page) is not reachable over this transport."
)
}
}
fn ms(d: Duration) -> f64 {
d.as_secs_f64() * 1000.0
}
async fn measure_tau(s: &mut Session) -> Result<Duration> {
let mut samples = Vec::with_capacity(TAU_REPS);
for _ in 0..TAU_REPS {
let id = s.alloc_id();
let t = Instant::now();
s.queue(REALPATH, &Enc::new().u32(id).str(b".").done())
.await?;
s.flush().await?;
let r = s.recv().await?;
ensure!(r.id == id, "reply id {} does not match request {id}", r.id);
samples.push(t.elapsed());
}
samples.sort_unstable();
Ok(samples[samples.len() / 2])
}
async fn list(s: &mut Session, dir: &str) -> Result<Vec<(String, Attrs)>> {
let id = s.alloc_id();
s.queue(OPENDIR, &Enc::new().u32(id).str(dir.as_bytes()).done())
.await?;
s.flush().await?;
let r = s.recv().await?;
ensure!(
r.kind == HANDLE,
"opendir {dir} refused (reply type {})",
r.kind
);
let handle = Dec::new(r.payload())
.str()
.context("opendir handle")?
.to_vec();
let mut out = Vec::new();
loop {
let id = s.alloc_id();
s.queue(READDIR, &Enc::new().u32(id).str(&handle).done())
.await?;
s.flush().await?;
let r = s.recv().await?;
if r.kind == STATUS {
break;
}
ensure!(r.kind == NAME, "readdir gave reply type {}", r.kind);
let mut d = Dec::new(r.payload());
let count = d.u32().context("readdir count")?;
for _ in 0..count {
let name = String::from_utf8_lossy(d.str().context("filename")?).into_owned();
d.str().context("longname")?;
let attrs = Attrs::decode(&mut d).context("attrs")?;
out.push((name, attrs));
}
}
let id = s.alloc_id();
s.queue(CLOSE, &Enc::new().u32(id).str(&handle).done())
.await?;
s.flush().await?;
s.recv().await?;
Ok(out)
}
async fn batch_open(s: &mut Session, paths: &[String]) -> Result<(Duration, Vec<Vec<u8>>)> {
let t = Instant::now();
for p in paths {
let id = s.alloc_id();
s.queue(
OPEN,
&Enc::new()
.u32(id)
.str(p.as_bytes())
.u32(FXF_READ)
.u32(0)
.done(),
)
.await?;
}
s.flush().await?;
let mut handles = Vec::with_capacity(paths.len());
for _ in 0..paths.len() {
let r = s.recv().await?;
ensure!(r.kind == HANDLE, "open refused (reply type {})", r.kind);
handles.push(Dec::new(r.payload()).str().context("open handle")?.to_vec());
}
Ok((t.elapsed(), handles))
}
async fn batch_read(s: &mut Session, handles: &[Vec<u8>]) -> Result<(Duration, usize)> {
let t = Instant::now();
for h in handles {
let id = s.alloc_id();
s.queue(READ, &Enc::new().u32(id).str(h).u64(0).u32(READ_LEN).done())
.await?;
}
s.flush().await?;
let mut bytes = 0usize;
for _ in 0..handles.len() {
let r = s.recv().await?;
match r.kind {
DATA => bytes += Dec::new(r.payload()).str().map_or(0, |b| b.len()),
STATUS => {}
other => bail!("read gave reply type {other}"),
}
}
Ok((t.elapsed(), bytes))
}
async fn batch_close(s: &mut Session, handles: &[Vec<u8>]) -> Result<()> {
for h in handles {
let id = s.alloc_id();
s.queue(CLOSE, &Enc::new().u32(id).str(h).done()).await?;
}
s.flush().await?;
for _ in 0..handles.len() {
s.recv().await?;
}
Ok(())
}