Skip to main content

pito_jobs/
live.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{Arc, Mutex};
5use std::thread::{self, JoinHandle};
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9
10use super::env::{MACOS, data_dir, jobs_dir};
11use super::host::{host_name, pid_start};
12use super::util::{self, Clock, atomic_write, filled};
13
14const CAP: usize = 2048;
15const ONE_US: u64 = 1_000_000;
16const FIVE_US: u64 = 5_000_000;
17const FILE_LIMIT: usize = 4096;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Surface {
21    Tui,
22    Desk,
23    Service,
24}
25
26impl Surface {
27    fn as_str(self) -> &'static str {
28        match self {
29            Surface::Tui => "tui",
30            Surface::Desk => "desk",
31            Surface::Service => "service",
32        }
33    }
34
35    fn target_fps(self) -> f64 {
36        match self {
37            Surface::Tui => 120.0,
38            Surface::Desk => 60.0,
39            Surface::Service => 0.0,
40        }
41    }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Pacing {
46    Continuous,
47    OnChange,
48}
49
50impl Pacing {
51    fn as_str(self) -> &'static str {
52        match self {
53            Pacing::Continuous => "continuous",
54            Pacing::OnChange => "on_change",
55        }
56    }
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
60#[serde(default)]
61pub struct Span {
62    pub p50: f64,
63    #[serde(default)]
64    pub p95: f64,
65    pub p99: f64,
66    pub max: f64,
67}
68
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70pub struct Beat {
71    #[serde(default)]
72    pub v: u32,
73    #[serde(default)]
74    pub id: String,
75    #[serde(default)]
76    pub host: String,
77    #[serde(default)]
78    pub pid: u32,
79    #[serde(default)]
80    pub pid_start: u64,
81    #[serde(default)]
82    pub app: String,
83    #[serde(default)]
84    pub version: String,
85    #[serde(default)]
86    pub surface: String,
87    #[serde(default)]
88    pub ride: bool,
89    #[serde(default)]
90    pub fps: f64,
91    #[serde(default)]
92    pub frame_ms: Option<Span>,
93    #[serde(default)]
94    pub gpu_ms: Option<Span>,
95    #[serde(default)]
96    pub interval_ms: Option<Span>,
97    #[serde(default)]
98    pub missed: u64,
99    #[serde(default)]
100    pub target_fps: f64,
101    #[serde(default)]
102    pub pacing: String,
103    #[serde(default)]
104    pub idle: bool,
105    #[serde(default)]
106    pub detail: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub size: Option<[u32; 2]>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub renderer: Option<String>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub gpu_pass_ms: Option<BTreeMap<String, f64>>,
113    #[serde(default)]
114    pub ride_set: Option<String>,
115    #[serde(default)]
116    pub started: u64,
117    #[serde(default)]
118    pub updated: u64,
119}
120
121pub fn live(app: &str, version: &str, surface: Surface, pacing: Pacing) -> Handle {
122    open(
123        app,
124        version,
125        surface,
126        pacing,
127        Env::from_env(),
128        Clock::system(),
129        true,
130    )
131}
132
133pub fn apps_dir() -> Option<PathBuf> {
134    Env::from_env().dir
135}
136
137pub fn read_apps(path: impl AsRef<Path>) -> Vec<Beat> {
138    let Ok(entries) = std::fs::read_dir(path) else {
139        return Vec::new();
140    };
141    let mut found = Vec::new();
142    for entry in entries.flatten() {
143        let name = entry.file_name();
144        let name = name.to_string_lossy();
145        if name.starts_with('.') {
146            continue;
147        }
148        let Ok(meta) = entry.metadata() else {
149            continue;
150        };
151        if !meta.is_file() {
152            continue;
153        }
154        let Some(bytes) = crate::util::read_capped(&entry.path()) else {
155            continue;
156        };
157        let Ok(beat) = serde_json::from_slice::<Beat>(&bytes) else {
158            continue;
159        };
160        if beat.app.is_empty() {
161            continue;
162        }
163        found.push((name.into_owned(), beat));
164    }
165    found.sort_by(|left, right| left.0.cmp(&right.0));
166    found.into_iter().map(|(_, beat)| beat).collect()
167}
168
169#[must_use = "dropping the handle removes the heartbeat file"]
170pub struct Handle {
171    inner: Option<Arc<Inner>>,
172    stop: Option<Arc<AtomicBool>>,
173    thread: Option<JoinHandle<()>>,
174}
175
176impl Handle {
177    pub fn frame(&self, work: Duration) {
178        self.push(false, work, None);
179    }
180
181    pub fn frame_with(&self, cpu: Duration, gpu: Option<Duration>) {
182        self.push(false, cpu, gpu);
183    }
184
185    pub fn frame_idle(&self) {
186        self.push(true, Duration::ZERO, None);
187    }
188
189    pub fn detail(&self, text: &str) {
190        let Some(inner) = &self.inner else {
191            return;
192        };
193        let line = one_line(text, 240);
194        let mut slot = inner.detail.lock().unwrap_or_else(|err| err.into_inner());
195        *slot = if line.is_empty() { None } else { Some(line) };
196    }
197
198    pub fn size(&self, w: u32, h: u32) {
199        if let Some(inner) = &self.inner {
200            *inner.size.lock().unwrap_or_else(|err| err.into_inner()) = Some([w, h]);
201        }
202    }
203
204    pub fn renderer(&self, name: &str) {
205        if let Some(inner) = &self.inner {
206            let name = short_line(name, 16);
207            *inner.renderer.lock().unwrap_or_else(|err| err.into_inner()) =
208                (!name.is_empty()).then_some(name);
209        }
210    }
211
212    pub fn passes(&self, passes: &[(&str, f64)]) {
213        if let Some(inner) = &self.inner {
214            let mut values = BTreeMap::new();
215            for &(name, ms) in passes {
216                if values.len() >= 16 {
217                    break;
218                }
219                if !ms.is_finite() || ms < 0.0 {
220                    continue;
221                }
222                let name = short_line(name, 24);
223                if !name.is_empty() {
224                    values.insert(name, ms);
225                }
226            }
227            *inner.passes.lock().unwrap_or_else(|err| err.into_inner()) = Some(values);
228        }
229    }
230
231    fn push(&self, idle: bool, cpu: Duration, gpu: Option<Duration>) {
232        let Some(inner) = &self.inner else {
233            return;
234        };
235        let seq = inner.next.fetch_add(1, Ordering::Relaxed);
236        let slot = &inner.slots[(seq as usize) % CAP];
237        slot.at.store(inner.clock.mono_us(), Ordering::Relaxed);
238        slot.cpu.store(as_ns(cpu), Ordering::Relaxed);
239        slot.gpu
240            .store(gpu.map(as_ns).unwrap_or(u64::MAX), Ordering::Relaxed);
241        slot.flags.store(u64::from(idle), Ordering::Relaxed);
242        slot.seq.store(seq + 1, Ordering::Release);
243    }
244}
245
246impl Drop for Handle {
247    fn drop(&mut self) {
248        if let Some(stop) = &self.stop {
249            stop.store(true, Ordering::Release);
250        }
251        if let Some(thread) = self.thread.take() {
252            thread.thread().unpark();
253            let _ = thread.join();
254        }
255        if let Some(inner) = &self.inner {
256            inner.remove_file();
257        }
258    }
259}
260
261struct Slot {
262    seq: AtomicU64,
263    at: AtomicU64,
264    cpu: AtomicU64,
265    gpu: AtomicU64,
266    flags: AtomicU64,
267}
268
269impl Slot {
270    fn new() -> Slot {
271        Slot {
272            seq: AtomicU64::new(0),
273            at: AtomicU64::new(0),
274            cpu: AtomicU64::new(0),
275            gpu: AtomicU64::new(0),
276            flags: AtomicU64::new(0),
277        }
278    }
279}
280
281struct Mark {
282    at_us: u64,
283    cpu_ns: u64,
284    gpu_ns: Option<u64>,
285    idle: bool,
286}
287
288struct Accum {
289    cursor: u64,
290    marks: Vec<Mark>,
291}
292
293struct Measured {
294    fps: f64,
295    idle: bool,
296    frame: Option<Span>,
297    gpu: Option<Span>,
298    interval: Option<Span>,
299    missed: u64,
300}
301
302struct Inner {
303    clock: Clock,
304    slots: Vec<Slot>,
305    next: AtomicU64,
306    accum: Mutex<Accum>,
307    detail: Mutex<Option<String>>,
308    size: Mutex<Option<[u32; 2]>>,
309    renderer: Mutex<Option<String>>,
310    passes: Mutex<Option<BTreeMap<String, f64>>>,
311    wrote: AtomicBool,
312    last_us: AtomicU64,
313    dir: PathBuf,
314    file: String,
315    app: String,
316    version: String,
317    surface: Surface,
318    pacing: Pacing,
319    target_fps: f64,
320    id: String,
321    host: String,
322    pid: u32,
323    pid_start: u64,
324    ride: bool,
325    ride_set: Option<String>,
326    started_ms: u64,
327}
328
329impl Inner {
330    fn write_if_due(&self) {
331        let now = self.clock.mono_us();
332        self.collect(now);
333        if self.wrote.load(Ordering::Relaxed) {
334            let last = self.last_us.load(Ordering::Relaxed);
335            if now.saturating_sub(last) < ONE_US {
336                return;
337            }
338        }
339        let measured = self.measured(now);
340        let detail = self
341            .detail
342            .lock()
343            .unwrap_or_else(|err| err.into_inner())
344            .clone();
345        let size = *self.size.lock().unwrap_or_else(|err| err.into_inner());
346        let renderer = self
347            .renderer
348            .lock()
349            .unwrap_or_else(|err| err.into_inner())
350            .clone();
351        let passes = self
352            .passes
353            .lock()
354            .unwrap_or_else(|err| err.into_inner())
355            .clone();
356        let beat = self.beat(&measured, detail, size, renderer, passes);
357        let bytes = encode(&beat);
358        if bytes.is_empty() {
359            return;
360        }
361        if atomic_write(&self.dir, &self.file, &bytes).is_ok() {
362            self.last_us.store(now, Ordering::Relaxed);
363            self.wrote.store(true, Ordering::Relaxed);
364        }
365    }
366
367    fn collect(&self, now: u64) {
368        let mut accum = self.accum.lock().unwrap_or_else(|err| err.into_inner());
369        drain(&self.slots, &mut accum);
370        prune(&mut accum.marks, now);
371    }
372
373    fn measured(&self, now: u64) -> Measured {
374        let accum = self.accum.lock().unwrap_or_else(|err| err.into_inner());
375        let target = match self.pacing {
376            Pacing::Continuous => self.target_fps,
377            Pacing::OnChange => 0.0,
378        };
379        measure(&accum.marks, now, target)
380    }
381
382    fn beat(
383        &self,
384        measured: &Measured,
385        detail: Option<String>,
386        size: Option<[u32; 2]>,
387        renderer: Option<String>,
388        gpu_pass_ms: Option<BTreeMap<String, f64>>,
389    ) -> Beat {
390        Beat {
391            v: 1,
392            id: self.id.clone(),
393            host: self.host.clone(),
394            pid: self.pid,
395            pid_start: self.pid_start,
396            app: self.app.clone(),
397            version: self.version.clone(),
398            surface: self.surface.as_str().to_string(),
399            ride: self.ride,
400            fps: measured.fps,
401            frame_ms: measured.frame.clone(),
402            gpu_ms: measured.gpu.clone(),
403            interval_ms: measured.interval.clone(),
404            missed: measured.missed,
405            target_fps: self.target_fps,
406            pacing: self.pacing.as_str().to_string(),
407            idle: measured.idle,
408            detail,
409            size,
410            renderer,
411            gpu_pass_ms,
412            ride_set: self.ride_set.clone(),
413            started: self.started_ms,
414            updated: self.clock.wall_ms(),
415        }
416    }
417
418    fn remove_file(&self) {
419        let _ = std::fs::remove_file(self.dir.join(&self.file));
420    }
421}
422
423fn open(
424    app: &str,
425    version: &str,
426    surface: Surface,
427    pacing: Pacing,
428    env: Env,
429    clock: Clock,
430    spawn: bool,
431) -> Handle {
432    let Some(dir) = env.dir else {
433        return Handle {
434            inner: None,
435            stop: None,
436            thread: None,
437        };
438    };
439    if env.off || !util::WRITES {
440        return Handle {
441            inner: None,
442            stop: None,
443            thread: None,
444        };
445    }
446    let started_ms = clock.wall_ms();
447    let pid = util::process_id();
448    let host = host_name();
449    let file = format!("{pid}-{started_ms}.json");
450    let inner = Arc::new(Inner {
451        clock,
452        slots: (0..CAP).map(|_| Slot::new()).collect(),
453        next: AtomicU64::new(0),
454        accum: Mutex::new(Accum {
455            cursor: 0,
456            marks: Vec::new(),
457        }),
458        detail: Mutex::new(None),
459        size: Mutex::new(None),
460        renderer: Mutex::new(None),
461        passes: Mutex::new(None),
462        wrote: AtomicBool::new(false),
463        last_us: AtomicU64::new(0),
464        dir,
465        file,
466        app: one_line(app, 128),
467        version: one_line(version, 128),
468        surface,
469        pacing,
470        target_fps: surface.target_fps(),
471        id: format!("{host}:{pid}:{started_ms}"),
472        host,
473        pid,
474        pid_start: pid_start(),
475        ride: env.ride,
476        ride_set: env.ride_set.map(|set| one_line(&set, 128)),
477        started_ms,
478    });
479    if !spawn {
480        return Handle {
481            inner: Some(inner),
482            stop: None,
483            thread: None,
484        };
485    }
486    let stop = Arc::new(AtomicBool::new(false));
487    let thread = {
488        let inner = Arc::clone(&inner);
489        let stop = Arc::clone(&stop);
490        thread::Builder::new()
491            .name("pito-live".to_string())
492            .spawn(move || worker(inner, stop))
493            .ok()
494    };
495    Handle {
496        inner: Some(inner),
497        stop: Some(stop),
498        thread,
499    }
500}
501
502fn worker(inner: Arc<Inner>, stop: Arc<AtomicBool>) {
503    be_nice();
504    while !stop.load(Ordering::Acquire) {
505        inner.write_if_due();
506        if stop.load(Ordering::Acquire) {
507            break;
508        }
509        thread::park_timeout(Duration::from_millis(200));
510    }
511    inner.remove_file();
512}
513
514#[cfg(target_os = "linux")]
515fn be_nice() {
516    unsafe {
517        let tid = libc::gettid();
518        libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, 19);
519        libc::syscall(libc::SYS_ioprio_set, 1, 0, 3 << 13);
520    }
521}
522
523#[cfg(not(target_os = "linux"))]
524fn be_nice() {}
525
526fn drain(slots: &[Slot], accum: &mut Accum) {
527    for _ in 0..CAP.saturating_mul(2) {
528        let slot = &slots[(accum.cursor as usize) % CAP];
529        let seq = slot.seq.load(Ordering::Acquire);
530        let expect = accum.cursor + 1;
531        if seq == expect {
532            let at = slot.at.load(Ordering::Relaxed);
533            let cpu = slot.cpu.load(Ordering::Relaxed);
534            let gpu = slot.gpu.load(Ordering::Relaxed);
535            let flags = slot.flags.load(Ordering::Relaxed);
536            if slot.seq.load(Ordering::Acquire) != expect {
537                break;
538            }
539            accum.marks.push(Mark {
540                at_us: at,
541                cpu_ns: cpu,
542                gpu_ns: (gpu != u64::MAX).then_some(gpu),
543                idle: flags == 1,
544            });
545            accum.cursor += 1;
546            continue;
547        }
548        if seq > expect {
549            accum.cursor = seq - 1;
550            continue;
551        }
552        break;
553    }
554}
555
556fn prune(marks: &mut Vec<Mark>, now: u64) {
557    if let Some(index) = marks
558        .iter()
559        .position(|mark| now.saturating_sub(mark.at_us) <= FIVE_US)
560    {
561        let drop_n = index.saturating_sub(1);
562        if drop_n > 0 {
563            marks.drain(..drop_n);
564        }
565    } else if marks.len() > 1 {
566        let last = marks.pop().unwrap();
567        marks.clear();
568        marks.push(last);
569    }
570    if marks.len() > 8192 {
571        let drop_n = marks.len() - 8192;
572        marks.drain(..drop_n);
573    }
574}
575
576fn measure(marks: &[Mark], now: u64, target_fps: f64) -> Measured {
577    let mut frames = Vec::new();
578    let mut gpus = Vec::new();
579    let mut gaps = Vec::new();
580    let mut fps = 0u64;
581    let mut missed = 0u64;
582    let mut prev: Option<u64> = None;
583    let limit = if target_fps > 0.0 {
584        1.5 * (ONE_US as f64 / target_fps)
585    } else {
586        0.0
587    };
588    for mark in marks {
589        if mark.idle {
590            continue;
591        }
592        let age = now.saturating_sub(mark.at_us);
593        let in_five = age <= FIVE_US;
594        if age <= ONE_US {
595            fps += 1;
596        }
597        if let Some(before) = prev
598            && in_five
599        {
600            let gap = mark.at_us.saturating_sub(before);
601            gaps.push(gap as f64 / 1000.0);
602            if limit > 0.0 && gap as f64 > limit {
603                missed += 1;
604            }
605        }
606        prev = Some(mark.at_us);
607        if in_five {
608            frames.push(mark.cpu_ns as f64 / 1_000_000.0);
609            if let Some(gpu) = mark.gpu_ns {
610                gpus.push(gpu as f64 / 1_000_000.0);
611            }
612        }
613    }
614    Measured {
615        fps: fps as f64,
616        idle: fps == 0,
617        frame: span_of(&frames),
618        gpu: span_of(&gpus),
619        interval: span_of(&gaps),
620        missed,
621    }
622}
623
624fn span_of(values: &[f64]) -> Option<Span> {
625    if values.is_empty() {
626        return None;
627    }
628    let mut sorted = values.to_vec();
629    sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
630    Some(Span {
631        p50: round1(percentile(&sorted, 0.50)),
632        p95: round1(percentile(&sorted, 0.95)),
633        p99: round1(percentile(&sorted, 0.99)),
634        max: round1(sorted[sorted.len() - 1]),
635    })
636}
637
638fn percentile(sorted: &[f64], p: f64) -> f64 {
639    let n = sorted.len();
640    let idx = ((p * n as f64).ceil() as usize)
641        .saturating_sub(1)
642        .min(n - 1);
643    sorted[idx]
644}
645
646fn round1(value: f64) -> f64 {
647    (value * 10.0).round() / 10.0
648}
649
650fn encode(beat: &Beat) -> Vec<u8> {
651    let Ok(bytes) = serde_json::to_vec(beat) else {
652        return Vec::new();
653    };
654    if bytes.len() <= FILE_LIMIT {
655        return bytes;
656    }
657    let mut copy = beat.clone();
658    copy.detail = None;
659    match serde_json::to_vec(&copy) {
660        Ok(bytes) if bytes.len() <= FILE_LIMIT => bytes,
661        _ => Vec::new(),
662    }
663}
664
665fn as_ns(duration: Duration) -> u64 {
666    u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
667}
668
669fn one_line(text: &str, max: usize) -> String {
670    let text = text.split(['\n', '\r']).next().unwrap_or("");
671    let mut end = text.len().min(max);
672    while end > 0 && !text.is_char_boundary(end) {
673        end -= 1;
674    }
675    text[..end].to_string()
676}
677
678fn short_line(text: &str, max: usize) -> String {
679    text.chars()
680        .take_while(|ch| *ch != '\n' && *ch != '\r')
681        .filter(|ch| !ch.is_control())
682        .take(max)
683        .collect()
684}
685
686#[derive(Clone, Debug)]
687struct Env {
688    dir: Option<PathBuf>,
689    off: bool,
690    ride: bool,
691    ride_set: Option<String>,
692}
693
694impl Env {
695    fn from_env() -> Env {
696        Self::from_fn(|key| std::env::var(key).ok(), |path| path.is_dir())
697    }
698
699    fn from_fn(mut get: impl FnMut(&str) -> Option<String>, exists: impl Fn(&Path) -> bool) -> Env {
700        let asked_off = get("PITO_JOBS")
701            .as_deref()
702            .is_some_and(|value| value.trim() == "off");
703        let data = data_dir(&mut get, MACOS);
704        let installed = filled(get("PITO_JOBS_DIR")).is_some()
705            || data.is_some_and(|data| exists(&data.join("pito").join("apps")));
706        let off = asked_off || !installed;
707        let ride_app = filled(get("RIDE_APP"));
708        let ride_set = filled(get("RIDE_SET"));
709        let dir = jobs_dir(&mut get, MACOS).map(|dir| dir.join("apps"));
710        Env {
711            dir,
712            off,
713            ride: ride_app.is_some(),
714            ride_set,
715        }
716    }
717}
718
719#[cfg(test)]
720impl Handle {
721    fn drive(&self) {
722        if let Some(inner) = &self.inner {
723            inner.write_if_due();
724        }
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn read_apps_skips_a_file_over_four_kilobytes_and_keeps_one_at_the_cap() {
734        let dir = scratch("apps-cap");
735        let kept = capped_beat(crate::util::READ_CAP);
736        let mut over = kept.clone();
737        over.push(b' ');
738        std::fs::write(dir.join("kept.json"), &kept).unwrap();
739        std::fs::write(dir.join("over.json"), &over).unwrap();
740        let beats = read_apps(&dir);
741        assert_eq!(beats.len(), 1);
742        assert_eq!(beats[0].app, "paper");
743        assert_eq!(kept.len(), crate::util::READ_CAP);
744    }
745
746    fn capped_beat(len: usize) -> Vec<u8> {
747        let prefix = br#"{"app":"paper","id":""#;
748        let suffix = br#""}"#;
749        let pad = len - prefix.len() - suffix.len();
750        let mut bytes = Vec::with_capacity(len);
751        bytes.extend_from_slice(prefix);
752        bytes.extend(std::iter::repeat_n(b'a', pad));
753        bytes.extend_from_slice(suffix);
754        assert_eq!(bytes.len(), len);
755        bytes
756    }
757
758    fn scratch(name: &str) -> PathBuf {
759        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
760            .join("tmp")
761            .join(name);
762        let _ = std::fs::remove_dir_all(&path);
763        std::fs::create_dir_all(&path).unwrap();
764        path
765    }
766
767    fn env_at(dir: &Path) -> Env {
768        Env {
769            dir: Some(dir.to_path_buf()),
770            off: false,
771            ride: false,
772            ride_set: None,
773        }
774    }
775
776    fn mark(at_ms: u64, cpu_ms: f64, gpu_ms: Option<f64>, idle: bool) -> Mark {
777        Mark {
778            at_us: at_ms * 1000,
779            cpu_ns: (cpu_ms * 1_000_000.0).round() as u64,
780            gpu_ns: gpu_ms.map(|ms| (ms * 1_000_000.0).round() as u64),
781            idle,
782        }
783    }
784
785    #[test]
786    fn percentiles_use_the_nearest_rank_over_the_sorted_window() {
787        let values: Vec<f64> = (1..=100).map(|n| n as f64).collect();
788        assert_eq!(percentile(&values, 0.50), 50.0);
789        assert_eq!(percentile(&values, 0.95), 95.0);
790        assert_eq!(percentile(&values, 0.99), 99.0);
791        assert_eq!(percentile(&values, 0.50), span_of(&values).unwrap().p50);
792        assert_eq!(span_of(&values).unwrap().p95, 95.0);
793        let one = span_of(&[4.25]).unwrap();
794        assert_eq!(one.p50, 4.3);
795        assert_eq!(one.p95, 4.3);
796        assert_eq!(one.p99, 4.3);
797        assert_eq!(one.max, 4.3);
798        assert!(span_of(&[]).is_none());
799    }
800
801    #[test]
802    fn fps_is_the_last_second_and_percentiles_the_last_five() {
803        let mut marks = Vec::new();
804        for i in 0..100 {
805            let at = 5_000 + i * 50;
806            let gpu = (i % 2 == 0).then_some(i as f64 + 0.5);
807            marks.push(mark(at, (i + 1) as f64, gpu, false));
808        }
809        marks.push(mark(9_900, 0.1, None, true));
810        let now = 10_000_000u64;
811        let got = measure(&marks, now, 120.0);
812        assert_eq!(got.fps, 20.0);
813        assert!(!got.idle);
814        let frame = got.frame.unwrap();
815        assert_eq!(frame.p50, 50.0);
816        assert_eq!(frame.p95, 95.0);
817        assert_eq!(frame.p99, 99.0);
818        assert_eq!(frame.max, 100.0);
819        let gpu = got.gpu.unwrap();
820        assert_eq!(gpu.max, 98.5);
821        assert_eq!(gpu.p95, 94.5);
822        let interval = got.interval.unwrap();
823        assert_eq!(interval.p50, 50.0);
824        assert_eq!(interval.p95, 50.0);
825        assert_eq!(interval.max, 50.0);
826        assert_eq!(got.missed, 99);
827    }
828
829    #[test]
830    fn idle_ticks_do_not_count_and_a_short_gap_is_not_missed() {
831        let marks = vec![
832            mark(0, 1.0, None, false),
833            mark(8, 1.0, None, true),
834            mark(16, 2.0, Some(3.0), false),
835        ];
836        let got = measure(&marks, 20_000, 60.0);
837        assert_eq!(got.fps, 2.0);
838        assert!(!got.idle);
839        assert_eq!(got.interval.unwrap().p50, 16.0);
840        assert_eq!(got.missed, 0);
841        assert_eq!(got.gpu.unwrap().p99, 3.0);
842        let quiet = measure(&[mark(0, 1.0, None, true)], 2_000_000, 120.0);
843        assert_eq!(quiet.fps, 0.0);
844        assert!(quiet.idle);
845        assert!(quiet.frame.is_none());
846        assert_eq!(quiet.missed, 0);
847        let no_target = measure(&marks, 20_000, 0.0);
848        assert_eq!(no_target.missed, 0);
849    }
850
851    #[test]
852    fn an_old_anchor_still_opens_the_first_interval() {
853        let marks = vec![mark(0, 9.0, None, false), mark(6_000, 1.0, None, false)];
854        let got = measure(&marks, 6_000_000, 120.0);
855        assert_eq!(got.fps, 1.0);
856        assert_eq!(got.frame.unwrap().p50, 1.0);
857        assert_eq!(got.interval.unwrap().max, 6000.0);
858        assert_eq!(got.missed, 1);
859    }
860
861    #[test]
862    fn the_folder_follows_the_environment_and_off_clears_nothing_about_it() {
863        let off = Env::from_fn(
864            |key| match key {
865                "PITO_JOBS" => Some(" off ".to_string()),
866                "PITO_JOBS_DIR" => Some("/custom".to_string()),
867                "RIDE_APP" => Some(" paper ".to_string()),
868                "RIDE_SET" => Some(" desk-a ".to_string()),
869                _ => None,
870            },
871            |_| true,
872        );
873        assert!(off.off);
874        assert_eq!(off.dir.unwrap(), PathBuf::from("/custom/apps"));
875        assert!(off.ride);
876        assert_eq!(off.ride_set.as_deref(), Some("desk-a"));
877        let xdg = Env::from_fn(
878            |key| match key {
879                "XDG_RUNTIME_DIR" => Some("/run/user/1000".to_string()),
880                "HOME" => Some("/u".to_string()),
881                "RIDE_APP" => Some("  ".to_string()),
882                _ => None,
883            },
884            |path| {
885                let data = if MACOS {
886                    "/u/Library/Application Support"
887                } else {
888                    "/u/.local/share"
889                };
890                path == Path::new(data).join("pito").join("apps")
891            },
892        );
893        assert!(!xdg.off);
894        assert!(!xdg.ride);
895        assert_eq!(
896            xdg.dir.unwrap(),
897            PathBuf::from("/run/user/1000/pito-jobs/apps")
898        );
899        let missing = Env::from_fn(|_| None, |_| false);
900        assert!(missing.dir.is_none());
901    }
902
903    #[test]
904    fn a_buyers_machine_gets_an_inert_handle() {
905        let runtime = |key: &str| match key {
906            "XDG_RUNTIME_DIR" => Some("/run/user/1000".to_string()),
907            "HOME" => Some("/buyer".to_string()),
908            _ => None,
909        };
910        assert!(Env::from_fn(runtime, |_| false).off);
911        let data_home = Env::from_fn(
912            |key| match key {
913                "XDG_RUNTIME_DIR" => Some("/run/user/1000".to_string()),
914                "XDG_DATA_HOME" => Some("/data".to_string()),
915                _ => None,
916            },
917            |path| path == Path::new("/data/pito/apps"),
918        );
919        assert!(!data_home.off);
920        let pinned = Env::from_fn(
921            |key| match key {
922                "PITO_JOBS_DIR" => Some("/tmp-under-test".to_string()),
923                _ => None,
924            },
925            |_| false,
926        );
927        assert!(!pinned.off);
928    }
929
930    #[test]
931    fn writes_wait_a_second_and_off_writes_nothing() {
932        let dir = scratch("live-throttle");
933        let (clock, us) = Clock::manual(10_000_000);
934        let handle = open(
935            "paper",
936            "0.12.0",
937            Surface::Tui,
938            Pacing::OnChange,
939            env_at(&dir),
940            clock,
941            false,
942        );
943        handle.frame(Duration::from_millis(2));
944        handle.drive();
945        let first = read_apps(&dir);
946        assert_eq!(first.len(), 1);
947        assert_eq!(first[0].fps, 1.0);
948        assert_eq!(first[0].updated, 10_000);
949        us.store(10_000_000 + 999_000, Ordering::Relaxed);
950        handle.frame(Duration::from_millis(2));
951        handle.drive();
952        assert_eq!(read_apps(&dir)[0].fps, 1.0);
953        us.store(10_000_000 + 1_000_000, Ordering::Relaxed);
954        handle.drive();
955        let second = read_apps(&dir);
956        assert_eq!(second[0].fps, 2.0);
957        assert_eq!(second[0].updated, 11_000);
958        drop(handle);
959
960        let quiet = scratch("live-off");
961        let (clock, _) = Clock::manual(1_000);
962        let mut cfg = env_at(&quiet);
963        cfg.off = true;
964        let handle = open(
965            "paper",
966            "0.1.0",
967            Surface::Desk,
968            Pacing::Continuous,
969            cfg,
970            clock,
971            false,
972        );
973        handle.frame(Duration::from_millis(1));
974        handle.frame_idle();
975        handle.detail("nope");
976        handle.size(3840, 2160);
977        handle.renderer("live");
978        handle.passes(&[("shadow", 1.4)]);
979        handle.drive();
980        assert!(read_apps(&quiet).is_empty());
981    }
982
983    #[test]
984    fn the_file_round_trips_ride_detail_and_drop_removes_it() {
985        let dir = scratch("live-round");
986        let (clock, us) = Clock::manual(2_000_000);
987        let mut cfg = env_at(&dir);
988        cfg.ride = true;
989        cfg.ride_set = Some("desk-a".to_string());
990        let handle = open(
991            "studio",
992            "0.4.0",
993            Surface::Desk,
994            Pacing::Continuous,
995            cfg,
996            clock,
997            false,
998        );
999        for i in 0..5 {
1000            us.store(2_000_000 + i * 16_000, Ordering::Relaxed);
1001            handle.frame_with(
1002                Duration::from_micros(1_500),
1003                Some(Duration::from_micros(4_000)),
1004            );
1005        }
1006        handle.frame_idle();
1007        handle.detail("converging 128/512 spp\nsecond");
1008        handle.size(3840, 2160);
1009        handle.renderer("live\nignored");
1010        handle.passes(&[("shadow", 1.4), ("opaque", 3.9)]);
1011        handle.drive();
1012        let beat = read_apps(&dir).pop().unwrap();
1013        assert_eq!(beat.v, 1);
1014        assert_eq!(beat.app, "studio");
1015        assert_eq!(beat.version, "0.4.0");
1016        assert_eq!(beat.surface, "desk");
1017        assert_eq!(beat.pacing, "continuous");
1018        assert!(beat.ride);
1019        assert_eq!(beat.ride_set.as_deref(), Some("desk-a"));
1020        assert_eq!(beat.detail.as_deref(), Some("converging 128/512 spp"));
1021        assert_eq!(beat.size, Some([3840, 2160]));
1022        assert_eq!(beat.renderer.as_deref(), Some("live"));
1023        assert_eq!(beat.gpu_pass_ms.as_ref().unwrap()["shadow"], 1.4);
1024        assert_eq!(beat.fps, 5.0);
1025        assert!(!beat.idle);
1026        assert_eq!(beat.target_fps, 60.0);
1027        assert_eq!(beat.frame_ms.unwrap().p50, 1.5);
1028        assert_eq!(beat.gpu_ms.unwrap().p99, 4.0);
1029        assert_eq!(beat.interval_ms.unwrap().p50, 16.0);
1030        assert_eq!(beat.missed, 0);
1031        assert!(beat.id.contains(&beat.pid.to_string()));
1032        let raw = std::fs::read_dir(&dir)
1033            .unwrap()
1034            .flatten()
1035            .find(|entry| entry.file_name().to_string_lossy().ends_with(".json"))
1036            .unwrap();
1037        assert!(
1038            raw.file_name()
1039                .to_string_lossy()
1040                .starts_with(&format!("{}-", beat.pid))
1041        );
1042        drop(handle);
1043        assert!(read_apps(&dir).is_empty());
1044    }
1045
1046    #[test]
1047    fn passes_drop_invalid_values_and_cap_names_and_count() {
1048        let dir = scratch("live-passes");
1049        let (clock, us) = Clock::manual(3_000_000);
1050        let handle = open(
1051            "studio",
1052            "0.4.0",
1053            Surface::Desk,
1054            Pacing::Continuous,
1055            env_at(&dir),
1056            clock,
1057            false,
1058        );
1059        let names: Vec<_> = (0..17).map(|i| format!("pass{i:02}")).collect();
1060        let mut input: Vec<_> = names.iter().map(|name| (name.as_str(), 1.0)).collect();
1061        let long = "x".repeat(40);
1062        input.insert(0, (long.as_str(), 2.0));
1063        input.insert(1, ("nan", f64::NAN));
1064        input.insert(2, ("negative", -1.0));
1065        handle.passes(&input);
1066        handle.drive();
1067        let beat = read_apps(&dir).pop().unwrap();
1068        let passes = beat.gpu_pass_ms.unwrap();
1069        assert_eq!(passes.len(), 16);
1070        assert_eq!(passes[&"x".repeat(24)], 2.0);
1071        assert!(!passes.contains_key("nan"));
1072        assert!(!passes.contains_key("negative"));
1073        assert!(passes.contains_key("pass14"));
1074        assert!(!passes.contains_key("pass15"));
1075        handle.renderer(&"é".repeat(20));
1076        handle.passes(&[(&"é".repeat(30), 0.0)]);
1077        us.store(4_000_000, Ordering::Relaxed);
1078        handle.drive();
1079        let beat = read_apps(&dir).pop().unwrap();
1080        assert_eq!(beat.renderer.unwrap().chars().count(), 16);
1081        assert_eq!(
1082            beat.gpu_pass_ms
1083                .unwrap()
1084                .keys()
1085                .next()
1086                .unwrap()
1087                .chars()
1088                .count(),
1089            24
1090        );
1091        drop(handle);
1092    }
1093
1094    #[test]
1095    fn a_wall_clock_step_back_does_not_stop_the_heartbeat() {
1096        let dir = scratch("live-wall-step");
1097        let (clock, wall, mono) = Clock::split(10_000_000_000);
1098        let handle = open(
1099            "paper",
1100            "0.12.0",
1101            Surface::Tui,
1102            Pacing::OnChange,
1103            env_at(&dir),
1104            clock,
1105            false,
1106        );
1107        handle.frame(Duration::from_millis(2));
1108        handle.drive();
1109        assert_eq!(read_apps(&dir)[0].updated, 10_000_000);
1110        wall.store(5_000_000_000, Ordering::Relaxed);
1111        mono.store(10_000_000_000 + 1_000_000, Ordering::Relaxed);
1112        handle.frame(Duration::from_millis(2));
1113        handle.drive();
1114        let beat = &read_apps(&dir)[0];
1115        assert_eq!(beat.updated, 5_000_000);
1116        assert_eq!(beat.fps, 2.0);
1117        assert_eq!(beat.started, 10_000_000);
1118        drop(handle);
1119    }
1120
1121    #[test]
1122    fn every_maximum_heartbeat_field_fits_the_file_limit() {
1123        let dir = scratch("live-limit");
1124        let (clock, _) = Clock::manual(u64::MAX - 1_000_000);
1125        let mut cfg = env_at(&dir);
1126        cfg.ride = true;
1127        cfg.ride_set = Some("s".repeat(128));
1128        let handle = open(
1129            &"a".repeat(128),
1130            &"v".repeat(128),
1131            Surface::Desk,
1132            Pacing::Continuous,
1133            cfg,
1134            clock,
1135            false,
1136        );
1137        handle.detail(&"d".repeat(240));
1138        handle.size(u32::MAX, u32::MAX);
1139        handle.renderer(&"r".repeat(16));
1140        let names: Vec<_> = (0..16)
1141            .map(|i| format!("{i:02}{}", "p".repeat(22)))
1142            .collect();
1143        let passes: Vec<_> = names.iter().map(|name| (name.as_str(), f64::MAX)).collect();
1144        handle.passes(&passes);
1145        handle.drive();
1146        let path = dir.join(&handle.inner.as_ref().unwrap().file);
1147        let bytes = std::fs::read(path).unwrap();
1148        assert!(bytes.len() <= FILE_LIMIT, "{} bytes", bytes.len());
1149        let mut beat: Beat = serde_json::from_slice(&bytes).unwrap();
1150        assert_eq!(beat.detail.as_ref().unwrap().len(), 240);
1151        assert_eq!(beat.gpu_pass_ms.as_ref().unwrap().len(), 16);
1152        beat.host = "h".repeat(64);
1153        beat.id = format!("{}:{}:{}", beat.host, u32::MAX, u64::MAX);
1154        beat.pid = u32::MAX;
1155        beat.pid_start = u64::MAX;
1156        beat.fps = f64::MAX;
1157        let span = Span {
1158            p50: f64::MAX,
1159            p95: f64::MAX,
1160            p99: f64::MAX,
1161            max: f64::MAX,
1162        };
1163        beat.frame_ms = Some(span.clone());
1164        beat.gpu_ms = Some(span.clone());
1165        beat.interval_ms = Some(span);
1166        beat.missed = u64::MAX;
1167        beat.target_fps = f64::MAX;
1168        beat.started = u64::MAX;
1169        beat.updated = u64::MAX;
1170        let full = encode(&beat);
1171        assert!(!full.is_empty());
1172        assert!(full.len() <= FILE_LIMIT, "{} bytes", full.len());
1173        let decoded: Beat = serde_json::from_slice(&full).unwrap();
1174        assert!(decoded.detail.is_some());
1175        assert_eq!(decoded.gpu_pass_ms.unwrap().len(), 16);
1176        drop(handle);
1177    }
1178
1179    #[test]
1180    fn old_beat_without_new_fields_still_parses() {
1181        let old = r#"{"v":1,"app":"studio","frame_ms":{"p50":1.9,"p99":6.8,"max":9.1},"gpu_ms":{"p50":7.8,"p99":8.3,"max":8.9},"interval_ms":{"p50":8.3,"p99":9.4,"max":17.2}}"#;
1182        let beat: Beat = serde_json::from_str(old).unwrap();
1183        assert_eq!(beat.frame_ms.unwrap().p95, 0.0);
1184        assert_eq!(beat.gpu_ms.unwrap().p95, 0.0);
1185        assert_eq!(beat.interval_ms.unwrap().p95, 0.0);
1186        assert_eq!(beat.size, None);
1187        assert_eq!(beat.renderer, None);
1188        assert_eq!(beat.gpu_pass_ms, None);
1189    }
1190
1191    #[test]
1192    fn an_on_change_surface_misses_nothing_while_it_idles() {
1193        let dir = scratch("live-on-change");
1194        let (clock, us) = Clock::manual(10_000_000);
1195        let handle = open(
1196            "paper",
1197            "0.12.0",
1198            Surface::Tui,
1199            Pacing::OnChange,
1200            env_at(&dir),
1201            clock,
1202            false,
1203        );
1204        for _ in 0..4 {
1205            us.fetch_add(250_000, Ordering::Relaxed);
1206            handle.frame(Duration::from_micros(2_000));
1207        }
1208        handle.drive();
1209        let beat = read_apps(&dir).pop().unwrap();
1210        assert_eq!(beat.pacing, "on_change");
1211        assert_eq!(beat.fps, 4.0);
1212        assert_eq!(beat.missed, 0);
1213        drop(handle);
1214    }
1215
1216    #[test]
1217    fn a_service_writes_without_frames_and_garbage_is_skipped() {
1218        let dir = scratch("live-service");
1219        let (clock, _) = Clock::manual(4_000_000);
1220        let handle = open(
1221            "dispatch",
1222            "1.0.0",
1223            Surface::Service,
1224            Pacing::OnChange,
1225            env_at(&dir),
1226            clock,
1227            false,
1228        );
1229        handle.drive();
1230        let beat = read_apps(&dir).pop().unwrap();
1231        assert_eq!(beat.app, "dispatch");
1232        assert_eq!(beat.surface, "service");
1233        assert_eq!(beat.fps, 0.0);
1234        assert!(beat.idle);
1235        assert!(beat.frame_ms.is_none());
1236        assert_eq!(beat.target_fps, 0.0);
1237        assert_eq!(beat.pacing, "on_change");
1238        std::fs::write(dir.join(".hidden.json"), b"{\"app\":\"nope\"}").unwrap();
1239        std::fs::write(dir.join("notes.txt"), b"hello").unwrap();
1240        std::fs::write(dir.join("extra.json"), b"{\"extra\":true,\"pid\":3}").unwrap();
1241        let beats = read_apps(&dir);
1242        assert_eq!(beats.len(), 1);
1243        assert_eq!(beats[0].app, "dispatch");
1244        drop(handle);
1245    }
1246
1247    #[test]
1248    fn the_writer_thread_publishes_and_drop_removes_the_file() {
1249        let dir = scratch("live-thread");
1250        let handle = open(
1251            "paper",
1252            "0.1.0",
1253            Surface::Tui,
1254            Pacing::OnChange,
1255            env_at(&dir),
1256            Clock::system(),
1257            true,
1258        );
1259        handle.frame(Duration::from_millis(1));
1260        let mut appeared = false;
1261        for _ in 0..40 {
1262            if !read_apps(&dir).is_empty() {
1263                appeared = true;
1264                break;
1265            }
1266            thread::sleep(Duration::from_millis(50));
1267        }
1268        assert!(appeared, "heartbeat file did not appear");
1269        drop(handle);
1270        assert!(read_apps(&dir).is_empty());
1271    }
1272}