use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
const TARGET_FPS: f32 = 120.0;
const FPS_LIMIT: u32 = 5000;
const SAMPLE_DURATION: Duration = Duration::from_secs(8);
const WARMUP_SAMPLES: usize = 2;
#[derive(Debug, Clone, Copy)]
struct FrameStats {
fps: f32,
max_frame_ms: f32,
}
fn parse_frame_stats(line: &str) -> Option<FrameStats> {
let rest = &line[line.find("frame_stats:")?..];
let value_after = |key: &str| -> Option<f32> {
rest.split(key)
.nth(1)?
.split_whitespace()
.next()?
.parse()
.ok()
};
Some(FrameStats {
fps: value_after("fps=")?,
max_frame_ms: value_after("max_frame_ms=")?,
})
}
struct Gled {
child: Child,
stats: Receiver<FrameStats>,
}
impl Gled {
fn start() -> Self {
let bin = std::env::var("GLED_BIN").unwrap_or_else(|_| "./target/release/gled".to_string());
let mut command = Command::new(&bin);
command
.env("GLED_FPS_LIMIT", FPS_LIMIT.to_string())
.env("RUST_LOG", "gled=debug")
.stdout(Stdio::piped())
.stderr(Stdio::null());
if std::env::var_os("WINIT_UNIX_BACKEND").is_none() {
command.env("WINIT_UNIX_BACKEND", "x11");
}
let mut child = command.spawn().unwrap_or_else(|_| {
panic!("Failed to start {bin} — run `cargo build --release` first (or set GLED_BIN)")
});
let pipe = child.stdout.take().expect("stdout piped");
let (tx, stats) = mpsc::channel();
thread::spawn(move || {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if let Some(sample) = parse_frame_stats(&line)
&& tx.send(sample).is_err()
{
break;
}
}
});
Gled { child, stats }
}
}
impl Drop for Gled {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
println!("[✓] gled terminated");
}
}
#[test]
fn test_fps_no_vsync_regression() {
let gled = Gled::start();
let first = gled
.stats
.recv_timeout(Duration::from_secs(30))
.expect("No frame_stats received — does the environment have a display/GPU?");
println!(
"[✓] gled started, first sample: fps={:.1} max_frame_ms={:.3}",
first.fps, first.max_frame_ms
);
for _ in 0..WARMUP_SAMPLES {
let _ = gled.stats.recv_timeout(Duration::from_secs(5));
}
let mut samples = Vec::new();
let deadline = Instant::now() + SAMPLE_DURATION;
while Instant::now() < deadline {
if let Ok(sample) = gled.stats.recv_timeout(Duration::from_secs(5)) {
println!(
" sample: fps={:.1} max_frame_ms={:.3}",
sample.fps, sample.max_frame_ms
);
samples.push(sample);
}
}
assert!(
!samples.is_empty(),
"No frame_stats samples collected during the measurement window"
);
let best_fps = samples.iter().map(|s| s.fps).fold(0.0_f32, f32::max);
let median_fps = {
let mut fps: Vec<f32> = samples.iter().map(|s| s.fps).collect();
fps.sort_by(|a, b| a.partial_cmp(b).expect("fps is finite"));
fps[fps.len() / 2]
};
let worst_frame_ms = samples
.iter()
.map(|s| s.max_frame_ms)
.fold(0.0_f32, f32::max);
println!(
"── result: best_fps={best_fps:.1} median_fps={median_fps:.1} worst_frame_ms={worst_frame_ms:.3} (target {TARGET_FPS} fps)"
);
assert!(
median_fps >= TARGET_FPS,
"Sustained frame rate too low: median {median_fps:.1} fps < {TARGET_FPS} fps target \
(best {best_fps:.1} fps, worst frame {worst_frame_ms:.3} ms — likely a surface/vsync stall)"
);
}