use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
use std::time::Instant;
pub const WRITER_JOIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
#[derive(Debug, Clone)]
pub struct Frame {
pub packed: Vec<u8>,
pub input_hash: u64,
}
impl Frame {
pub fn new(packed: Vec<u8>, raw_input: &[u8]) -> Self {
Self::with_hash(packed, hash_bytes(raw_input))
}
pub fn with_hash(packed: Vec<u8>, input_hash: u64) -> Self {
Self { packed, input_hash }
}
}
pub type SnapshotJob = Box<dyn FnOnce() + Send>;
pub fn hash_bytes(bytes: &[u8]) -> u64 {
let mut h = DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
pub fn changed_span(prev: &[u8], next: &[u8]) -> Option<(usize, usize)> {
debug_assert_eq!(prev.len(), next.len(), "changed_span needs equal lengths");
let len = prev.len().min(next.len());
let mut first = 0;
while first < len && prev[first] == next[first] {
first += 1;
}
if first == len {
return None;
}
let mut last = len - 1;
while last > first && prev[last] == next[last] {
last -= 1;
}
Some((first, last))
}
pub trait FrameSink: Send {
fn write_frame(&mut self, buf: &[u8]) -> std::io::Result<()>;
}
pub struct VirtualSink {
path: std::path::PathBuf,
}
impl VirtualSink {
pub fn open(path: impl Into<std::path::PathBuf>) -> Self {
Self { path: path.into() }
}
}
impl FrameSink for VirtualSink {
fn write_frame(&mut self, buf: &[u8]) -> std::io::Result<()> {
let mut tmp = self.path.clone().into_os_string();
tmp.push(".tmp");
let tmp = std::path::PathBuf::from(tmp);
std::fs::write(&tmp, buf)?;
std::fs::rename(&tmp, &self.path)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WriterStats {
pub writes: u64,
pub drops: u64,
pub skipped_duplicates: u64,
pub last_write_ms: Option<f64>,
}
struct Shared {
pending: Mutex<PendingState>,
cv: Condvar,
writes: AtomicU64,
drops: AtomicU64,
skipped: AtomicU64,
last_write_ms_bits: AtomicU64,
stop: std::sync::atomic::AtomicBool,
}
struct PendingState {
frame: Option<Frame>,
last_written_hash: Option<u64>,
snapshot: Option<SnapshotJob>,
}
const NO_LAST_WRITE: u64 = u64::MAX;
pub struct FbWriter {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
impl FbWriter {
pub fn spawn<S: FrameSink + 'static>(sink: S) -> Self {
let shared = Arc::new(Shared {
pending: Mutex::new(PendingState {
frame: None,
last_written_hash: None,
snapshot: None,
}),
cv: Condvar::new(),
writes: AtomicU64::new(0),
drops: AtomicU64::new(0),
skipped: AtomicU64::new(0),
last_write_ms_bits: AtomicU64::new(NO_LAST_WRITE),
stop: std::sync::atomic::AtomicBool::new(false),
});
let thread = {
let shared = shared.clone();
std::thread::Builder::new()
.name("fb-writer".to_string())
.spawn(move || writer_loop(shared, sink))
.expect("spawn fb writer thread")
};
Self {
shared,
thread: Some(thread),
}
}
pub fn present(&self, frame: Frame) {
{
let mut p = self.shared.pending.lock().unwrap();
if p.frame.is_some() {
self.shared.drops.fetch_add(1, Ordering::Relaxed);
}
p.frame = Some(frame);
}
self.shared.cv.notify_one();
}
pub fn is_last(&self, input_hash: u64) -> bool {
let p = self.shared.pending.lock().unwrap();
p.frame.as_ref().map(|f| f.input_hash) == Some(input_hash)
|| p.last_written_hash == Some(input_hash)
}
pub fn request_snapshot(&self, job: SnapshotJob) {
self.shared.pending.lock().unwrap().snapshot = Some(job);
self.shared.cv.notify_one();
}
pub fn stats(&self) -> WriterStats {
let bits = self.shared.last_write_ms_bits.load(Ordering::Relaxed);
WriterStats {
writes: self.shared.writes.load(Ordering::Relaxed),
drops: self.shared.drops.load(Ordering::Relaxed),
skipped_duplicates: self.shared.skipped.load(Ordering::Relaxed),
last_write_ms: if bits == NO_LAST_WRITE {
None
} else {
Some(round2(f64::from_bits(bits)))
},
}
}
pub fn cleanup(&mut self) {
self.shared.stop.store(true, Ordering::SeqCst);
self.shared.cv.notify_all();
if let Some(thread) = self.thread.take() {
let deadline = Instant::now() + WRITER_JOIN_TIMEOUT;
while !thread.is_finished() && Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
if thread.is_finished() {
let _ = thread.join();
}
}
}
}
impl Drop for FbWriter {
fn drop(&mut self) {
self.cleanup();
}
}
fn writer_loop<S: FrameSink>(shared: Arc<Shared>, mut sink: S) {
loop {
let stop = shared.stop.load(Ordering::SeqCst);
let (frame, snapshot, last_hash) = {
let mut p = shared.pending.lock().unwrap();
if p.frame.is_none() && p.snapshot.is_none() && !stop {
let (guard, _timeout) = shared
.cv
.wait_timeout(p, std::time::Duration::from_millis(500))
.unwrap();
p = guard;
}
(p.frame.take(), p.snapshot.take(), p.last_written_hash)
};
if frame.is_none() && snapshot.is_none() {
if shared.stop.load(Ordering::SeqCst) {
return;
}
continue;
}
if let Some(frame) = frame {
if last_hash == Some(frame.input_hash) {
shared.skipped.fetch_add(1, Ordering::Relaxed);
} else {
let t0 = Instant::now();
match sink.write_frame(&frame.packed) {
Ok(()) => {
let ms = t0.elapsed().as_secs_f64() * 1000.0;
shared
.last_write_ms_bits
.store(ms.to_bits(), Ordering::Relaxed);
shared.writes.fetch_add(1, Ordering::Relaxed);
shared.pending.lock().unwrap().last_written_hash = Some(frame.input_hash);
}
Err(e) => {
tracing::warn!(error = %e, "framebuffer write failed");
}
}
}
}
if let Some(job) = snapshot {
job();
}
if shared.stop.load(Ordering::SeqCst) {
let has_more = shared.pending.lock().unwrap().frame.is_some();
if !has_more {
return;
}
}
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
#[cfg(target_os = "linux")]
pub struct MmapSink {
_file: std::fs::File,
map: memmap2::MmapMut,
frame_bytes: usize,
last: Vec<u8>,
}
#[cfg(target_os = "linux")]
impl MmapSink {
pub fn open(fb_path: &str, frame_bytes: usize) -> std::io::Result<Self> {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(fb_path)?;
let map = unsafe {
memmap2::MmapOptions::new()
.len(frame_bytes)
.map_mut(&file)?
};
Ok(Self {
_file: file,
map,
frame_bytes,
last: Vec::new(),
})
}
}
#[cfg(target_os = "linux")]
impl FrameSink for MmapSink {
fn write_frame(&mut self, buf: &[u8]) -> std::io::Result<()> {
let len = buf.len().min(self.frame_bytes);
let src = &buf[..len];
if self.last.len() != len {
self.map[..len].copy_from_slice(src);
self.map.flush()?;
self.last.clear();
self.last.extend_from_slice(src);
return Ok(());
}
match changed_span(&self.last, src) {
None => Ok(()),
Some((first, last)) => {
self.map[first..=last].copy_from_slice(&src[first..=last]);
self.map.flush_range(first, last - first + 1)?;
self.last[first..=last].copy_from_slice(&src[first..=last]);
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
#[test]
fn virtual_sink_writes_the_latest_frame_to_its_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fb.bin");
let mut sink = VirtualSink::open(&path);
sink.write_frame(&[1, 2, 3, 4]).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), vec![1, 2, 3, 4]);
sink.write_frame(&[9, 9]).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), vec![9, 9]);
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp");
assert!(
!std::path::Path::new(&tmp).exists(),
"the temp sibling is renamed away"
);
}
#[test]
fn concurrent_reader_never_sees_a_torn_frame() {
use std::sync::atomic::{AtomicBool, Ordering};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fb.bin");
let small = vec![0xAAu8; 64];
let large = vec![0xBBu8; 4096];
VirtualSink::open(&path).write_frame(&small).unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let small_seen = std::sync::Arc::new(AtomicBool::new(false));
let large_seen = std::sync::Arc::new(AtomicBool::new(false));
let reader = {
let (path, stop) = (path.clone(), stop.clone());
let (small_seen, large_seen) = (small_seen.clone(), large_seen.clone());
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
if let Ok(bytes) = std::fs::read(&path) {
match bytes.len() {
64 => small_seen.store(true, Ordering::Relaxed),
4096 => large_seen.store(true, Ordering::Relaxed),
other => panic!("torn read: length {other} is neither full frame"),
}
}
}
})
};
let mut sink = VirtualSink::open(&path);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let mut i = 0u64;
while !(small_seen.load(Ordering::Relaxed) && large_seen.load(Ordering::Relaxed))
&& std::time::Instant::now() < deadline
{
sink.write_frame(if i.is_multiple_of(2) { &large } else { &small })
.unwrap();
i += 1;
}
for j in 0..400u64 {
sink.write_frame(if (i + j).is_multiple_of(2) {
&large
} else {
&small
})
.unwrap();
}
stop.store(true, Ordering::Relaxed);
reader.join().expect("reader saw only complete frames");
assert!(
small_seen.load(Ordering::Relaxed) && large_seen.load(Ordering::Relaxed),
"the reader observed both complete frames"
);
}
#[derive(Clone)]
struct FakeSink {
writes: Arc<Mutex<Vec<Vec<u8>>>>,
write_count: Arc<AtomicUsize>,
fail_on: Option<usize>,
delay: Option<std::time::Duration>,
}
impl FakeSink {
fn new() -> Self {
Self {
writes: Arc::new(Mutex::new(Vec::new())),
write_count: Arc::new(AtomicUsize::new(0)),
fail_on: None,
delay: None,
}
}
}
impl FrameSink for FakeSink {
fn write_frame(&mut self, buf: &[u8]) -> std::io::Result<()> {
if let Some(d) = self.delay {
std::thread::sleep(d);
}
let n = self.write_count.fetch_add(1, Ordering::SeqCst);
if self.fail_on == Some(n) {
return Err(std::io::Error::other("simulated SPI disconnect"));
}
self.writes.lock().unwrap().push(buf.to_vec());
Ok(())
}
}
fn wait_until<F: Fn() -> bool>(f: F) {
let deadline = Instant::now() + std::time::Duration::from_secs(2);
while !f() && Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn writes_a_frame_to_the_sink() {
let sink = FakeSink::new();
let recorded = sink.writes.clone();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![1, 2, 3, 4], b"input-a"));
wait_until(|| !recorded.lock().unwrap().is_empty());
w.cleanup();
let got = recorded.lock().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0], vec![1, 2, 3, 4]);
assert_eq!(w.stats().writes, 1);
assert!(w.stats().last_write_ms.is_some());
}
#[test]
fn duplicate_raw_input_skips_the_write() {
let sink = FakeSink::new();
let writes = sink.write_count.clone();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![1, 2, 3, 4], b"same-input"));
wait_until(|| writes.load(Ordering::SeqCst) == 1);
w.present(Frame::new(vec![9, 9, 9, 9], b"same-input"));
wait_until(|| w.stats().skipped_duplicates == 1);
w.cleanup();
assert_eq!(w.stats().writes, 1);
assert_eq!(w.stats().skipped_duplicates, 1);
assert_eq!(writes.load(Ordering::SeqCst), 1);
}
#[test]
fn changed_input_after_a_duplicate_writes_again() {
let sink = FakeSink::new();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![1], b"a"));
wait_until(|| w.stats().writes == 1);
w.present(Frame::new(vec![1], b"a")); wait_until(|| w.stats().skipped_duplicates == 1);
w.present(Frame::new(vec![2], b"b")); wait_until(|| w.stats().writes == 2);
w.cleanup();
assert_eq!(w.stats().writes, 2);
assert_eq!(w.stats().skipped_duplicates, 1);
}
#[test]
fn latest_wins_drops_the_superseded_pending_frame() {
let mut sink = FakeSink::new();
sink.delay = Some(std::time::Duration::from_millis(80));
let recorded = sink.writes.clone();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![0xA], b"frame-1"));
std::thread::sleep(std::time::Duration::from_millis(10));
w.present(Frame::new(vec![0xB], b"frame-2"));
w.present(Frame::new(vec![0xC], b"frame-3"));
wait_until(|| {
let r = recorded.lock().unwrap();
r.iter().any(|f| f == &vec![0xC])
});
w.cleanup();
assert!(w.stats().drops >= 1, "stats={:?}", w.stats());
let got = recorded.lock().unwrap();
assert!(got.iter().any(|f| f == &vec![0xA]));
assert!(got.iter().any(|f| f == &vec![0xC]));
assert!(!got.iter().any(|f| f == &vec![0xB]));
}
#[test]
fn drain_pending_frame_on_stop_still_writes() {
let sink = FakeSink::new();
let recorded = sink.writes.clone();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![0xEE], b"last-frame"));
w.cleanup();
let got = recorded.lock().unwrap();
assert!(
got.iter().any(|f| f == &vec![0xEE]),
"drained frame missing"
);
}
#[test]
fn a_failed_write_does_not_freeze_the_panel() {
let mut sink = FakeSink::new();
sink.fail_on = Some(1); let recorded = sink.writes.clone();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![1], b"a"));
wait_until(|| w.stats().writes == 1);
w.present(Frame::new(vec![2], b"b")); std::thread::sleep(std::time::Duration::from_millis(50));
w.present(Frame::new(vec![2], b"b")); wait_until(|| w.stats().writes == 2);
w.cleanup();
assert_eq!(w.stats().writes, 2, "the writer must keep serving");
assert_eq!(recorded.lock().unwrap().last(), Some(&vec![2]));
}
#[test]
fn snapshot_job_runs_on_the_writer_thread() {
let w = FbWriter::spawn(FakeSink::new());
let ran_on = Arc::new(Mutex::new(None::<String>));
let slot = ran_on.clone();
w.request_snapshot(Box::new(move || {
let name = std::thread::current().name().map(str::to_string);
*slot.lock().unwrap() = name;
}));
wait_until(|| ran_on.lock().unwrap().is_some());
assert_eq!(ran_on.lock().unwrap().as_deref(), Some("fb-writer"));
}
#[test]
fn cleanup_is_idempotent() {
let sink = FakeSink::new();
let mut w = FbWriter::spawn(sink);
w.present(Frame::new(vec![1], b"a"));
wait_until(|| w.stats().writes == 1);
w.cleanup();
w.cleanup(); assert_eq!(w.stats().writes, 1);
}
#[test]
fn stats_round_trip_starts_empty() {
let sink = FakeSink::new();
let w = FbWriter::spawn(sink);
let s = w.stats();
assert_eq!(s.writes, 0);
assert_eq!(s.drops, 0);
assert_eq!(s.skipped_duplicates, 0);
assert!(s.last_write_ms.is_none());
}
#[test]
fn hash_is_stable_and_distinguishes_input() {
assert_eq!(hash_bytes(b"abc"), hash_bytes(b"abc"));
assert_ne!(hash_bytes(b"abc"), hash_bytes(b"abd"));
}
#[test]
fn changed_span_is_none_for_identical_frames() {
let a = vec![0u8; 64];
let b = vec![0u8; 64];
assert_eq!(changed_span(&a, &b), None);
}
#[test]
fn changed_span_brackets_a_localized_change() {
let prev = vec![0u8; 64];
let mut next = prev.clone();
next[20] = 1;
next[21] = 2;
next[23] = 4;
assert_eq!(changed_span(&prev, &next), Some((20, 23)));
}
#[test]
fn changed_span_covers_first_and_last_byte() {
let prev = vec![0u8; 8];
let mut next = prev.clone();
next[0] = 9;
next[7] = 9;
assert_eq!(changed_span(&prev, &next), Some((0, 7)));
}
#[test]
fn changed_span_single_byte() {
let prev = vec![5u8; 16];
let mut next = prev.clone();
next[9] = 6;
assert_eq!(changed_span(&prev, &next), Some((9, 9)));
}
}