use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
pub const MAX_TRACE_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_INTERVAL_MS: u64 = 60_000;
#[derive(Debug, Clone, serde::Serialize)]
#[non_exhaustive]
pub struct TraceFrame {
pub t_ms: u64,
pub data_b64: String,
}
#[derive(Debug)]
pub struct Screencast {
interval_ms: AtomicU64,
max_frames: AtomicUsize,
trace: Mutex<TraceState>,
frames: Mutex<Vec<TraceFrame>>,
label: Mutex<Option<String>>,
}
#[derive(Debug, Default)]
struct TraceState {
active: bool,
generation: u64,
owned_recording: Option<u64>,
}
impl Default for Screencast {
fn default() -> Self {
Self {
interval_ms: AtomicU64::new(500),
max_frames: AtomicUsize::new(60),
trace: Mutex::new(TraceState::default()),
frames: Mutex::new(Vec::new()),
label: Mutex::new(None),
}
}
}
impl Screencast {
fn trace(&self) -> std::sync::MutexGuard<'_, TraceState> {
self.trace
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn start(
&self,
interval_ms: u64,
max_frames: usize,
label: Option<String>,
) -> (u64, Option<u64>) {
let mut t = self.trace();
self.interval_ms
.store(interval_ms.clamp(50, MAX_INTERVAL_MS), Ordering::Relaxed);
self.max_frames
.store(max_frames.clamp(1, 600), Ordering::Relaxed);
self.frames
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
*self
.label
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = label;
t.generation += 1;
t.active = true;
(t.generation, t.owned_recording.take())
}
pub fn stop(&self) -> (usize, Option<u64>) {
let owned = {
let mut t = self.trace();
t.active = false;
t.generation += 1;
t.owned_recording.take()
};
(self.frame_count(), owned)
}
#[must_use]
pub fn is_active(&self) -> bool {
self.trace().active
}
#[must_use]
pub fn generation(&self) -> u64 {
self.trace().generation
}
#[must_use]
pub fn is_current(&self, generation: u64) -> bool {
let t = self.trace();
t.active && t.generation == generation
}
#[must_use]
pub fn interval_ms(&self) -> u64 {
self.interval_ms.load(Ordering::Relaxed)
}
#[must_use]
pub fn label(&self) -> Option<String> {
self.label
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn push_frame(&self, t_ms: u64, data_b64: String) {
self.push_frame_capped(t_ms, data_b64, MAX_TRACE_BYTES);
}
fn push_frame_capped(&self, t_ms: u64, data_b64: String, max_bytes: usize) {
let max = self.max_frames.load(Ordering::Relaxed);
let mut f = self
.frames
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f.push(TraceFrame { t_ms, data_b64 });
let len = f.len();
if len > max {
f.drain(0..len - max);
}
let mut total: usize = f.iter().map(|fr| fr.data_b64.len()).sum();
let mut evict = 0;
while total > max_bytes && evict + 1 < f.len() {
total -= f[evict].data_b64.len();
evict += 1;
}
f.drain(0..evict);
}
pub fn push_frame_if_current(&self, generation: u64, t_ms: u64, data_b64: String) -> bool {
let t = self.trace();
if !(t.active && t.generation == generation) {
return false;
}
self.push_frame(t_ms, data_b64);
drop(t);
true
}
pub fn set_owned_recording(&self, generation: u64, recorder_generation: u64) -> bool {
let mut t = self.trace();
if t.active && t.generation == generation {
t.owned_recording = Some(recorder_generation);
true
} else {
false
}
}
pub fn stop_if_generation(&self, generation: u64) -> Option<Option<u64>> {
let mut t = self.trace();
if t.active && t.generation == generation {
t.active = false;
t.generation += 1;
Some(t.owned_recording.take())
} else {
None
}
}
#[must_use]
pub fn frame_count(&self) -> usize {
self.frames
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
#[must_use]
pub fn frames(&self, limit: usize) -> Vec<TraceFrame> {
let f = self
.frames
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if limit == 0 || limit >= f.len() {
f.clone()
} else {
f[f.len() - limit..].to_vec()
}
}
#[must_use]
pub fn frame_timestamps(&self) -> Vec<u64> {
self.frames
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.map(|fr| fr.t_ms)
.collect()
}
}
pub(crate) struct CaptureTaskGuard {
pub(crate) screencast: std::sync::Arc<Screencast>,
pub(crate) recorder: victauri_core::EventRecorder,
pub(crate) generation: u64,
}
impl Drop for CaptureTaskGuard {
fn drop(&mut self) {
if let Some(Some(owned)) = self.screencast.stop_if_generation(self.generation) {
let _ = self.recorder.stop_if_generation(owned);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_buffer_caps_frames() {
let sc = Screencast::default();
sc.start(100, 3, None);
for i in 0..5 {
sc.push_frame(i * 100, format!("frame{i}"));
}
assert_eq!(sc.frame_count(), 3, "should cap at max_frames");
let frames = sc.frames(0);
assert_eq!(frames[0].data_b64, "frame2");
assert_eq!(frames[2].data_b64, "frame4");
}
#[test]
fn start_clears_and_bumps_generation() {
let sc = Screencast::default();
let (g1, _) = sc.start(200, 10, Some("main".into()));
sc.push_frame(0, "x".into());
assert_eq!(sc.frame_count(), 1);
let (g2, _) = sc.start(200, 10, None);
assert!(g2 > g1, "generation must increase");
assert_eq!(sc.frame_count(), 0, "start clears frames");
assert!(sc.is_active());
}
#[test]
fn stop_deactivates_and_invalidates() {
let sc = Screencast::default();
let (g, _) = sc.start(200, 10, None);
sc.stop();
assert!(!sc.is_active());
assert!(sc.generation() > g, "stop invalidates the task generation");
}
#[test]
fn stale_generation_push_is_rejected() {
let sc = Screencast::default();
let (old, _) = sc.start(100, 10, None);
let (new, _) = sc.start(100, 10, None);
assert!(!sc.push_frame_if_current(old, 0, "stale".into()));
assert!(sc.push_frame_if_current(new, 0, "fresh".into()));
assert_eq!(sc.frames(0)[0].data_b64, "fresh");
sc.stop();
assert!(!sc.push_frame_if_current(new, 1, "after-stop".into()));
}
#[test]
fn byte_cap_evicts_oldest_but_keeps_newest() {
let sc = Screencast::default();
sc.start(100, 600, None);
let cap = 100;
sc.push_frame_capped(0, "x".repeat(51), cap);
sc.push_frame_capped(1, "y".repeat(51), cap);
sc.push_frame_capped(2, "tail".into(), cap);
let frames = sc.frames(0);
let total: usize = frames.iter().map(|f| f.data_b64.len()).sum();
assert!(total <= cap, "total {total} exceeds the cap");
assert_eq!(frames.len(), 2, "only the oldest frame should be evicted");
assert_eq!(frames.last().unwrap().data_b64, "tail");
}
#[test]
fn owned_recording_is_taken_once() {
let sc = Screencast::default();
let (g, _) = sc.start(100, 10, None);
assert!(sc.set_owned_recording(g, 7));
assert_eq!(sc.stop(), (0, Some(7)));
assert_eq!(sc.stop(), (0, None));
}
#[test]
fn owned_recording_belongs_to_its_trace_generation() {
let sc = Screencast::default();
let (old, _) = sc.start(100, 10, None);
assert!(sc.set_owned_recording(old, 1));
let (new, superseded) = sc.start(100, 10, None);
assert_eq!(superseded, Some(1));
assert!(sc.set_owned_recording(new, 2));
assert_eq!(sc.stop_if_generation(old), None);
assert!(!sc.set_owned_recording(old, 3));
assert_eq!(sc.stop_if_generation(new), Some(Some(2)));
}
#[test]
fn interval_is_clamped() {
let sc = Screencast::default();
let _ = sc.start(u64::MAX, 10, None);
assert_eq!(sc.interval_ms(), MAX_INTERVAL_MS);
let _ = sc.start(1, 10, None);
assert_eq!(sc.interval_ms(), 50);
}
#[test]
fn capture_task_guard_cleans_up_after_a_panic() {
let sc = std::sync::Arc::new(Screencast::default());
let recorder = victauri_core::EventRecorder::new(10);
let (g, _) = sc.start(100, 10, None);
let rg = recorder.start_session("trace".into()).unwrap();
assert!(sc.set_owned_recording(g, rg));
let guard = CaptureTaskGuard {
screencast: std::sync::Arc::clone(&sc),
recorder: recorder.clone(),
generation: g,
};
let r = std::thread::spawn(move || {
let _guard = guard;
panic!("capture blew up");
})
.join();
assert!(r.is_err());
assert!(!sc.is_active(), "trace left active after its task panicked");
assert!(!recorder.is_recording(), "owned recording left running");
}
#[test]
fn capture_task_guard_of_a_stale_trace_is_a_no_op() {
let sc = std::sync::Arc::new(Screencast::default());
let recorder = victauri_core::EventRecorder::new(10);
let (old, _) = sc.start(100, 10, None);
let (new, _) = sc.start(100, 10, None);
let rg = recorder.start_session("new".into()).unwrap();
assert!(sc.set_owned_recording(new, rg));
drop(CaptureTaskGuard {
screencast: std::sync::Arc::clone(&sc),
recorder: recorder.clone(),
generation: old,
});
assert!(sc.is_current(new));
assert!(recorder.is_recording());
}
#[test]
fn stale_generation_cannot_stop_a_newer_trace() {
let sc = Screencast::default();
let (old, _) = sc.start(100, 10, None);
let (new, _) = sc.start(100, 10, None);
assert!(
sc.stop_if_generation(old).is_none(),
"an old task must not stop the new trace"
);
assert!(sc.is_active());
assert!(sc.stop_if_generation(new).is_some());
assert!(!sc.is_active());
}
#[test]
fn concurrent_start_and_stop_never_orphan_an_active_trace() {
let sc = std::sync::Arc::new(Screencast::default());
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let (sc2, b2) = (std::sync::Arc::clone(&sc), std::sync::Arc::clone(&barrier));
let stopper = std::thread::spawn(move || {
for _ in 0..20_000 {
b2.wait();
let _ = sc2.stop();
b2.wait();
}
});
let mut orphaned = 0;
for _ in 0..20_000 {
barrier.wait();
let (g, _) = sc.start(100, 10, None);
barrier.wait();
if sc.is_active() && sc.generation() != g {
orphaned += 1;
}
}
stopper.join().unwrap();
assert_eq!(orphaned, 0, "active trace with no capture task");
}
#[test]
fn stale_frame_never_lands_in_a_newer_trace() {
let sc = std::sync::Arc::new(Screencast::default());
let (first, _) = sc.start(100, 600, None);
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let pushers: Vec<_> = (0..3)
.map(|_| {
let (sc, stop) = (std::sync::Arc::clone(&sc), std::sync::Arc::clone(&stop));
std::thread::spawn(move || {
let mut g = first;
while !stop.load(Ordering::Relaxed) {
if !sc.push_frame_if_current(g, 0, g.to_string()) {
g = sc.generation();
}
}
})
})
.collect();
let mut leaked = 0;
for _ in 0..20_000 {
let (g, _) = sc.start(100, 600, None);
leaked += sc
.frames(0)
.iter()
.filter(|f| f.data_b64.parse::<u64>().unwrap() != g)
.count();
}
stop.store(true, Ordering::Relaxed);
for p in pushers {
p.join().unwrap();
}
assert_eq!(
leaked, 0,
"frames from a superseded trace leaked into a newer one"
);
}
#[test]
fn frames_limit_returns_most_recent() {
let sc = Screencast::default();
sc.start(100, 100, None);
for i in 0..5 {
sc.push_frame(i, format!("f{i}"));
}
let last2 = sc.frames(2);
assert_eq!(last2.len(), 2);
assert_eq!(last2[0].data_b64, "f3");
assert_eq!(last2[1].data_b64, "f4");
}
}