use crate::render;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const MB: f64 = 1e6;
pub fn run(check: bool) -> Result<()> {
let factor: f64 = std::env::var("SNYVI_BENCH_FACTOR")
.ok()
.and_then(|f| f.parse().ok())
.unwrap_or(1.0);
let shared = std::env::var_os("SNYVI_BENCH_SHARED").is_some();
let fixtures = Fixtures::new();
let mut failed = render_rows(&fixtures, factor);
failed |= process_rows(&fixtures, factor, shared)?;
if check && failed {
bail!("bench: at least one case exceeded its budget");
}
Ok(())
}
struct Fixtures {
md_2k: &'static str,
md_100k: String,
md_1m: String,
code_10k: String,
code_100k: String,
}
impl Fixtures {
fn new() -> Self {
let section = "## Section heading\n\nA paragraph of ordinary prose with *emphasis*, **strong text**, `inline code`, and a [link](https://example.com). \
It runs on for a few sentences so the parser sees realistic line lengths and inline markup density.\n\n\
- one item\n- another item with `code`\n- a third\n\n\
| col a | col b | col c |\n|---|---|---|\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |\n\n\
> A quote that says something worth remembering.\n\n\
Another paragraph. Then more prose, because most documents are mostly prose, and the renderer should be judged on that.\n\n\
```rust\nfn main() {\n let x = 42;\n println!(\"{x}\");\n}\n```\n\n";
let repeat = |bytes: usize| -> String {
(0..bytes / section.len() + 1)
.map(|i| section.replacen("Section heading", &format!("Section {i}"), 1))
.collect()
};
let code = |lines: usize| -> String {
(0..lines)
.map(|i| format!("fn f{i}(x: u32) -> u32 {{ x + {i} }} // line\n"))
.collect()
};
Fixtures {
md_2k: section,
md_100k: repeat(100 * 1024),
md_1m: repeat(1024 * 1024),
code_10k: code(10_000),
code_100k: code(100_000),
}
}
}
fn render_rows(f: &Fixtures, factor: f64) -> bool {
let t0 = Instant::now();
let r = render::Renderer::new();
let init_ms = t0.elapsed().as_secs_f64() * 1000.0;
let cases: Vec<(&str, render::Kind, Option<&str>, &str, f64)> = vec![
("markdown 2 KB", render::Kind::Markdown, None, f.md_2k, 2.0),
(
"markdown 100 KB",
render::Kind::Markdown,
None,
&f.md_100k,
50.0,
),
(
"markdown 1 MB",
render::Kind::Markdown,
None,
&f.md_1m,
400.0,
),
(
"rust 10k lines (highlighted)",
render::Kind::Code,
Some("rs"),
&f.code_10k,
500.0,
),
(
"rust 100k lines (highlight capped at 256 KB)",
render::Kind::Code,
Some("rs"),
&f.code_100k,
500.0,
),
];
println!("renderer init: {init_ms:.1} ms (budget factor {factor})\n");
println!(
"{:<48} {:>9} {:>9} {:>9} {:>9}",
"case", "ms", "MB/s", "html KB", "budget"
);
let mut failed = false;
for (name, kind, lang, src, budget) in cases {
let _ = r.render(kind, lang, &src[..src.len().min(2048)]);
let mut best = f64::MAX;
let mut out_len = 0;
for _ in 0..3 {
let t = Instant::now();
let out = r.render(kind, lang, src);
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
out_len = out.len();
}
let budget = budget * factor;
let ok = best <= budget;
failed |= !ok;
let mbs = src.len() as f64 / 1e6 / (best / 1000.0);
println!(
"{name:<48} {best:>9.1} {mbs:>9.1} {:>9} {budget:>7.0}{}",
out_len / 1024,
if ok { " ok" } else { " OVER" }
);
}
failed
}
fn process_rows(f: &Fixtures, factor: f64, shared: bool) -> Result<bool> {
let exe = std::env::current_exe().context("locating snyvi binary")?;
let dir = std::env::temp_dir().join(format!("snyvi-bench-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).context("creating the bench's data directory")?;
let port = free_port()?;
println!(
"\n{:<48} {:>9} {:>9}{}",
"process",
"value",
"budget",
if shared { " (shared machine)" } else { "" }
);
let mut rows = Rows { failed: false };
let size = std::fs::metadata(&exe)?.len() as f64 / MB;
rows.size("binary size, snyvi", size, 15.0);
let mut daemon = None;
let mut start = f64::MAX;
for _ in 0..3 {
if let Some(d) = daemon.take() {
stop(d);
}
let (d, ms) = Daemon::start(&exe, &dir, port)?;
start = start.min(ms);
daemon = Some(d);
}
rows.time_unless(
"daemon cold start, to first health",
start,
100.0,
factor,
shared,
);
let daemon = daemon.expect("three starts leave one running");
let result = (|| -> Result<()> {
let mut send = f64::MAX;
let mut id = String::new();
for i in 0..3 {
let path = dir.join(format!("bench-{i}.md"));
std::fs::write(&path, format!("# Bench {i}\n\n{}", f.md_100k))?;
let (doc, ms) = daemon.send(&path)?;
send = send.min(ms);
id = doc;
}
rows.time("send 100 KB markdown, round trip", send, 100.0, factor);
rows.memory(
"daemon resident, three documents in, settled",
daemon.settled_mb(None)?,
60.0,
);
let mut ttfb = f64::MAX;
for _ in 0..3 {
ttfb = ttfb.min(daemon.first_byte(&format!("/d/{id}"))?);
}
rows.time("document page, time to first byte", ttfb, 30.0, factor);
let big_md = dir.join("bench-1mb.md");
let big_rs = dir.join("bench-100k.rs");
std::fs::write(&big_md, &f.md_1m)?;
std::fs::write(&big_rs, &f.code_100k)?;
let events = daemon.events()?;
daemon.send(&big_md)?;
daemon.send(&big_rs)?;
rows.memory(
"daemon resident, after 1 MB and 100k lines, settled",
daemon.settled_mb(Some((events, "rendered")))?,
100.0,
);
Ok(())
})();
stop(daemon);
let _ = std::fs::remove_dir_all(&dir);
result?;
if shared {
println!("\na budget in brackets is measured and not enforced: this machine's speed is not snyvi's to promise");
}
Ok(rows.failed)
}
struct Rows {
failed: bool,
}
impl Rows {
fn time(&mut self, name: &str, ms: f64, budget: f64, factor: f64) {
self.time_unless(name, ms, budget, factor, false);
}
fn time_unless(&mut self, name: &str, ms: f64, budget: f64, factor: f64, unenforced: bool) {
let budget = budget * factor;
let ok = ms <= budget;
self.failed |= !ok && !unenforced;
let budget = if unenforced {
format!("({budget:.0} ms)")
} else {
format!("{budget:.0} ms")
};
println!(
"{name:<48} {:>9} {budget:>9}{}",
format!("{ms:.1} ms"),
match (ok, unenforced) {
(true, _) => " ok",
(false, true) => " over, not enforced here",
(false, false) => " OVER",
}
);
}
fn size(&mut self, name: &str, mb: f64, budget: f64) {
let ok = mb <= budget;
self.failed |= !ok;
println!(
"{name:<48} {:>9} {:>9}{}",
format!("{mb:.1} MB"),
format!("{budget:.0} MB"),
if ok { " ok" } else { " OVER" }
);
}
fn memory(&mut self, name: &str, mb: Option<(f64, bool)>, budget: f64) {
match mb {
Some((mb, true)) => self.size(name, mb, budget),
Some((mb, false)) => println!(
"{name:<48} {:>9} {:>9} counts pages given back but not yet taken; not enforced",
format!("{mb:.1} MB"),
format!("({budget:.0} MB)")
),
None => println!(
"{name:<48} {:>9} {:>9} not measured here",
"-",
format!("{budget:.0} MB")
),
}
}
}
struct Daemon {
child: Child,
port: u16,
token: String,
}
impl Daemon {
fn start(exe: &Path, dir: &Path, port: u16) -> Result<(Daemon, f64)> {
let t0 = Instant::now();
let child = Command::new(exe)
.arg("serve")
.env("SNYVI_DATA_DIR", dir.join("data"))
.env("SNYVI_CONFIG_DIR", dir.join("config"))
.env("SNYVI_PORT", port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("starting a daemon for the bench")?;
let mut daemon = Daemon {
child,
port,
token: String::new(),
};
let deadline = t0 + Duration::from_secs(10);
let ms = loop {
if daemon.health().is_some() {
break t0.elapsed().as_secs_f64() * 1000.0;
}
if let Some(status) = daemon.child.try_wait()? {
bail!("the bench's daemon exited before answering ({status})");
}
if Instant::now() > deadline {
bail!("the bench's daemon did not answer on port {port} within ten seconds");
}
std::thread::sleep(Duration::from_millis(2));
};
daemon.token = std::fs::read_to_string(dir.join("config").join("token"))
.map(|t| t.trim().to_string())
.context("reading the bench daemon's token")?;
Ok((daemon, ms))
}
fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{path}", self.port)
}
fn health(&self) -> Option<Value> {
ureq::get(&self.url("/api/health"))
.config()
.timeout_global(Some(Duration::from_millis(400)))
.build()
.call()
.ok()?
.body_mut()
.read_json::<Value>()
.ok()
}
fn send(&self, path: &Path) -> Result<(String, f64)> {
let payload = serde_json::json!({
"path": path,
"cwd": path.parent(),
"origin": "cli",
});
let t = Instant::now();
let mut resp = ureq::post(&self.url("/api/docs"))
.header("Authorization", &format!("Bearer {}", self.token))
.config()
.timeout_global(Some(Duration::from_secs(30)))
.http_status_as_error(false)
.build()
.send_json(&payload)
.context("sending a bench document")?;
let status = resp.status().as_u16();
let body: Value = resp.body_mut().read_json().unwrap_or(Value::Null);
let ms = t.elapsed().as_secs_f64() * 1000.0;
if status >= 300 {
bail!("the bench's daemon refused a document ({status}): {body}");
}
let id = body
.pointer("/doc/id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("the send answered without a document id: {body}"))?
.to_string();
Ok((id, ms))
}
fn first_byte(&self, path: &str) -> Result<f64> {
let t = Instant::now();
let resp = ureq::get(&self.url(path))
.config()
.timeout_global(Some(Duration::from_secs(10)))
.build()
.call()
.with_context(|| format!("fetching {path} from the bench's daemon"))?;
let ms = t.elapsed().as_secs_f64() * 1000.0;
if resp.status().as_u16() != 200 {
bail!("{path} answered {}", resp.status());
}
Ok(ms)
}
fn events(&self) -> Result<Events> {
let resp = ureq::get(&self.url("/api/events"))
.config()
.timeout_global(Some(Duration::from_secs(120)))
.build()
.call()
.context("opening the bench daemon's event stream")?;
Ok(Events(BufReader::new(resp.into_body().into_reader())))
}
fn settled_mb(&self, done: Option<(Events, &str)>) -> Result<Option<(f64, bool)>> {
if let Some((events, name)) = done {
events.wait_for(name)?;
}
std::thread::sleep(Duration::from_secs(3));
Ok(self.resident_mb())
}
fn resident_mb(&self) -> Option<(f64, bool)> {
resident_bytes(&self.child).map(|(b, exact)| (b as f64 / MB, exact))
}
}
impl Drop for Daemon {
fn drop(&mut self) {
if matches!(self.child.try_wait(), Ok(None)) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
struct Events(BufReader<ureq::BodyReader<'static>>);
impl Events {
fn wait_for(mut self, name: &str) -> Result<()> {
let want = format!("event: {name}");
let mut line = String::new();
loop {
line.clear();
let n = self
.0
.read_line(&mut line)
.with_context(|| format!("waiting for the daemon's `{name}` event"))?;
if n == 0 {
bail!("the daemon's event stream ended before `{name}`");
}
if line.trim_end() == want {
return Ok(());
}
}
}
}
fn stop(mut d: Daemon) {
let _ = ureq::post(&d.url("/api/shutdown"))
.header("Authorization", &format!("Bearer {}", d.token))
.config()
.timeout_global(Some(Duration::from_secs(2)))
.http_status_as_error(false)
.build()
.send_empty();
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if matches!(d.child.try_wait(), Ok(Some(_))) {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
let _ = d.child.kill();
let _ = d.child.wait();
}
fn free_port() -> Result<u16> {
let l = std::net::TcpListener::bind("127.0.0.1:0").context("finding a free port")?;
Ok(l.local_addr()?.port())
}
#[cfg(target_os = "linux")]
fn resident_bytes(child: &Child) -> Option<(u64, bool)> {
let status = std::fs::read_to_string(format!("/proc/{}/status", child.id())).ok()?;
let kb: u64 = status
.lines()
.find_map(|l| l.strip_prefix("VmRSS:"))?
.trim()
.trim_end_matches("kB")
.trim()
.parse()
.ok()?;
Some((kb * 1024, true))
}
#[cfg(target_os = "macos")]
fn resident_bytes(child: &Child) -> Option<(u64, bool)> {
let pid = child.id().to_string();
let footprint = Command::new("vmmap")
.args(["--summary", &pid])
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let text = String::from_utf8_lossy(&o.stdout);
let value = text
.lines()
.find_map(|l| l.trim().strip_prefix("Physical footprint:"))?
.trim()
.to_string();
parse_size(&value)
});
if let Some(b) = footprint {
return Some((b, true));
}
let out = Command::new("ps")
.args(["-o", "rss=", "-p", &pid])
.output()
.ok()?;
let kb: u64 = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?;
Some((kb * 1024, false))
}
#[cfg(target_os = "macos")]
fn parse_size(s: &str) -> Option<u64> {
let (num, unit) = s.split_at(s.len().checked_sub(1)?);
let n: f64 = num.parse().ok()?;
let mul = match unit {
"K" => 1024.0,
"M" => 1024.0 * 1024.0,
"G" => 1024.0 * 1024.0 * 1024.0,
_ => return None,
};
Some((n * mul) as u64)
}
#[cfg(windows)]
fn resident_bytes(child: &Child) -> Option<(u64, bool)> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::ProcessStatus::{
GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
};
let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
counters.cb = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
let ok =
unsafe { GetProcessMemoryInfo(child.as_raw_handle() as _, &mut counters, counters.cb) };
(ok != 0).then_some((counters.WorkingSetSize as u64, true))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
fn resident_bytes(_: &Child) -> Option<(u64, bool)> {
None
}