use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime};
pub const SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct SnapshotConfig {
pub directory: PathBuf,
pub max_snapshots: usize,
pub min_interval: Duration,
pub check_request_file: bool,
pub auto_emit: bool,
}
impl Default for SnapshotConfig {
fn default() -> Self {
Self {
directory: PathBuf::from("target/framealloc"),
max_snapshots: 30,
min_interval: Duration::from_millis(500),
check_request_file: true,
auto_emit: false,
}
}
}
impl SnapshotConfig {
pub fn with_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.directory = dir.into();
self
}
pub fn with_max_snapshots(mut self, max: usize) -> Self {
self.max_snapshots = max;
self
}
pub fn with_min_interval(mut self, interval: Duration) -> Self {
self.min_interval = interval;
self
}
pub fn with_request_file(mut self, check: bool) -> Self {
self.check_request_file = check;
self
}
pub fn with_auto_emit(mut self, auto: bool) -> Self {
self.auto_emit = auto;
self
}
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub version: u32,
pub timestamp: String,
pub frame: u64,
pub duration_us: u64,
pub summary: SnapshotSummary,
pub threads: Vec<ThreadSnapshot>,
pub tags: Vec<TagSnapshot>,
pub promotions: PromotionStats,
pub transfers: TransferStats,
pub deferred: DeferredStats,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Default)]
pub struct SnapshotSummary {
pub frame_bytes: usize,
pub pool_bytes: usize,
pub heap_bytes: usize,
pub total_bytes: usize,
pub peak_bytes: usize,
}
#[derive(Debug, Clone)]
pub struct ThreadSnapshot {
pub id: String,
pub name: String,
pub frame_bytes: usize,
pub pool_bytes: usize,
pub heap_bytes: usize,
pub peak_bytes: usize,
pub budget: Option<BudgetInfo>,
}
#[derive(Debug, Clone)]
pub struct BudgetInfo {
pub limit: usize,
pub used: usize,
pub percent: u8,
}
#[derive(Debug, Clone)]
pub struct TagSnapshot {
pub path: String,
pub thread: String,
pub alloc_kind: String,
pub alloc_count: usize,
pub bytes: usize,
pub avg_lifetime_frames: f32,
pub promotion_rate: f32,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PromotionStats {
pub to_pool: usize,
pub to_heap: usize,
pub failed: usize,
}
#[derive(Debug, Clone, Default)]
pub struct TransferStats {
pub pending: usize,
pub completed_this_frame: usize,
}
#[derive(Debug, Clone, Default)]
pub struct DeferredStats {
pub queue_depth: usize,
pub processed_this_frame: usize,
}
#[derive(Debug, Clone)]
pub struct RuntimeDiagnostic {
pub code: String,
pub tag: Option<String>,
pub message: String,
}
impl Snapshot {
pub fn new(frame: u64) -> Self {
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| time_to_iso8601(d.as_secs()))
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
Self {
version: SNAPSHOT_VERSION,
timestamp,
frame,
duration_us: 0,
summary: SnapshotSummary::default(),
threads: Vec::new(),
tags: Vec::new(),
promotions: PromotionStats::default(),
transfers: TransferStats::default(),
deferred: DeferredStats::default(),
diagnostics: Vec::new(),
}
}
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration_us = duration.as_micros() as u64;
self
}
pub fn with_summary(mut self, summary: SnapshotSummary) -> Self {
self.summary = summary;
self
}
pub fn add_thread(&mut self, thread: ThreadSnapshot) {
self.threads.push(thread);
}
pub fn add_tag(&mut self, tag: TagSnapshot) {
self.tags.push(tag);
}
pub fn add_diagnostic(&mut self, diagnostic: RuntimeDiagnostic) {
self.diagnostics.push(diagnostic);
}
pub fn to_json(&self) -> String {
let estimated_size = 512
+ self.threads.len() * 256
+ self.tags.len() * 192
+ self.diagnostics.len() * 128;
let mut json = String::with_capacity(estimated_size.max(4096));
json.push_str("{\n");
json.push_str(&format!(" \"version\": {},\n", self.version));
json.push_str(&format!(" \"timestamp\": \"{}\",\n", escape_json_str(&self.timestamp)));
json.push_str(&format!(" \"frame\": {},\n", self.frame));
json.push_str(&format!(" \"duration_us\": {},\n", self.duration_us));
json.push_str(" \"summary\": {\n");
json.push_str(&format!(" \"frame_bytes\": {},\n", self.summary.frame_bytes));
json.push_str(&format!(" \"pool_bytes\": {},\n", self.summary.pool_bytes));
json.push_str(&format!(" \"heap_bytes\": {},\n", self.summary.heap_bytes));
json.push_str(&format!(" \"total_bytes\": {},\n", self.summary.total_bytes));
json.push_str(&format!(" \"peak_bytes\": {}\n", self.summary.peak_bytes));
json.push_str(" },\n");
json.push_str(" \"threads\": [\n");
for (i, thread) in self.threads.iter().enumerate() {
json.push_str(" {\n");
json.push_str(&format!(" \"id\": \"{}\",\n", escape_json_str(&thread.id)));
json.push_str(&format!(" \"name\": \"{}\",\n", escape_json_str(&thread.name)));
json.push_str(&format!(" \"frame_bytes\": {},\n", thread.frame_bytes));
json.push_str(&format!(" \"pool_bytes\": {},\n", thread.pool_bytes));
json.push_str(&format!(" \"heap_bytes\": {},\n", thread.heap_bytes));
json.push_str(&format!(" \"peak_bytes\": {},\n", thread.peak_bytes));
if let Some(ref budget) = thread.budget {
json.push_str(" \"budget\": {\n");
json.push_str(&format!(" \"limit\": {},\n", budget.limit));
json.push_str(&format!(" \"used\": {},\n", budget.used));
json.push_str(&format!(" \"percent\": {}\n", budget.percent));
json.push_str(" }\n");
} else {
json.push_str(" \"budget\": null\n");
}
if i < self.threads.len() - 1 {
json.push_str(" },\n");
} else {
json.push_str(" }\n");
}
}
json.push_str(" ],\n");
json.push_str(" \"tags\": [\n");
for (i, tag) in self.tags.iter().enumerate() {
json.push_str(" {\n");
json.push_str(&format!(" \"path\": \"{}\",\n", escape_json_str(&tag.path)));
json.push_str(&format!(" \"thread\": \"{}\",\n", escape_json_str(&tag.thread)));
json.push_str(&format!(" \"alloc_kind\": \"{}\",\n", escape_json_str(&tag.alloc_kind)));
json.push_str(&format!(" \"alloc_count\": {},\n", tag.alloc_count));
json.push_str(&format!(" \"bytes\": {},\n", tag.bytes));
json.push_str(&format!(" \"avg_lifetime_frames\": {:.2},\n", tag.avg_lifetime_frames));
json.push_str(&format!(" \"promotion_rate\": {:.2},\n", tag.promotion_rate));
json.push_str(" \"diagnostics\": [");
for (j, diag) in tag.diagnostics.iter().enumerate() {
json.push_str(&format!("\"{}\"", escape_json_str(diag)));
if j < tag.diagnostics.len() - 1 {
json.push_str(", ");
}
}
json.push_str("]\n");
if i < self.tags.len() - 1 {
json.push_str(" },\n");
} else {
json.push_str(" }\n");
}
}
json.push_str(" ],\n");
json.push_str(" \"promotions\": {\n");
json.push_str(&format!(" \"to_pool\": {},\n", self.promotions.to_pool));
json.push_str(&format!(" \"to_heap\": {},\n", self.promotions.to_heap));
json.push_str(&format!(" \"failed\": {}\n", self.promotions.failed));
json.push_str(" },\n");
json.push_str(" \"transfers\": {\n");
json.push_str(&format!(" \"pending\": {},\n", self.transfers.pending));
json.push_str(&format!(" \"completed_this_frame\": {}\n", self.transfers.completed_this_frame));
json.push_str(" },\n");
json.push_str(" \"deferred\": {\n");
json.push_str(&format!(" \"queue_depth\": {},\n", self.deferred.queue_depth));
json.push_str(&format!(" \"processed_this_frame\": {}\n", self.deferred.processed_this_frame));
json.push_str(" },\n");
json.push_str(" \"diagnostics\": [\n");
for (i, diag) in self.diagnostics.iter().enumerate() {
json.push_str(" {\n");
json.push_str(&format!(" \"code\": \"{}\",\n", escape_json_str(&diag.code)));
if let Some(ref tag) = diag.tag {
json.push_str(&format!(" \"tag\": \"{}\",\n", escape_json_str(tag)));
} else {
json.push_str(" \"tag\": null,\n");
}
json.push_str(&format!(" \"message\": \"{}\"\n", escape_json_str(&diag.message)));
if i < self.diagnostics.len() - 1 {
json.push_str(" },\n");
} else {
json.push_str(" }\n");
}
}
json.push_str(" ]\n");
json.push_str("}\n");
json
}
}
pub struct SnapshotEmitter {
config: SnapshotConfig,
last_emit: Mutex<Option<Instant>>,
enabled: AtomicBool,
emit_count: AtomicU64,
}
const CLEANUP_FREQUENCY: u64 = 5;
impl SnapshotEmitter {
pub fn new(config: SnapshotConfig) -> Self {
Self {
config,
last_emit: Mutex::new(None),
enabled: AtomicBool::new(true),
emit_count: AtomicU64::new(0),
}
}
pub fn set_enabled(&self, enabled: bool) {
self.enabled.store(enabled, Ordering::SeqCst);
}
pub fn is_enabled(&self) -> bool {
self.enabled.load(Ordering::SeqCst)
}
pub fn emit_count(&self) -> u64 {
self.emit_count.load(Ordering::Relaxed)
}
pub fn config(&self) -> &SnapshotConfig {
&self.config
}
pub fn maybe_emit(&self, snapshot: &Snapshot) -> bool {
if !self.is_enabled() {
return false;
}
let mut last = self.last_emit.lock().unwrap();
if let Some(last_time) = *last {
if last_time.elapsed() < self.config.min_interval {
return false;
}
}
let should_emit = self.config.auto_emit || self.check_request_file();
if should_emit {
*last = Some(Instant::now());
drop(last);
return self.emit_internal(snapshot);
}
false
}
pub fn emit(&self, snapshot: &Snapshot) -> bool {
if !self.is_enabled() {
return false;
}
{
let mut last = self.last_emit.lock().unwrap();
*last = Some(Instant::now());
}
self.emit_internal(snapshot)
}
fn emit_internal(&self, snapshot: &Snapshot) -> bool {
if let Err(e) = fs::create_dir_all(&self.config.directory) {
eprintln!("framealloc: failed to create snapshot directory: {}", e);
return false;
}
let filename = format!("snapshot_{:016}.json", snapshot.frame);
let path = self.config.directory.join(&filename);
match fs::File::create(&path) {
Ok(mut file) => {
let json = snapshot.to_json();
if let Err(e) = file.write_all(json.as_bytes()) {
eprintln!("framealloc: failed to write snapshot: {}", e);
return false;
}
}
Err(e) => {
eprintln!("framealloc: failed to create snapshot file: {}", e);
return false;
}
}
let count = self.emit_count.fetch_add(1, Ordering::Relaxed);
if count % CLEANUP_FREQUENCY == 0 {
self.cleanup_old_snapshots();
}
self.remove_request_file();
true
}
fn check_request_file(&self) -> bool {
if !self.config.check_request_file {
return false;
}
let request_path = self.config.directory.join("snapshot.request");
request_path.exists()
}
fn remove_request_file(&self) {
let request_path = self.config.directory.join("snapshot.request");
let _ = fs::remove_file(request_path);
}
fn cleanup_old_snapshots(&self) {
let dir = &self.config.directory;
let mut snapshots: Vec<_> = match fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with("snapshot_") && name.ends_with(".json")
})
.collect(),
Err(_) => return,
};
if snapshots.len() <= self.config.max_snapshots {
return;
}
snapshots.sort_by_key(|e| e.file_name());
let to_remove = snapshots.len() - self.config.max_snapshots;
for entry in snapshots.into_iter().take(to_remove) {
let _ = fs::remove_file(entry.path());
}
}
pub fn clear_all_snapshots(&self) -> std::io::Result<usize> {
let dir = &self.config.directory;
let mut removed = 0;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("snapshot_") && name.ends_with(".json") {
fs::remove_file(entry.path())?;
removed += 1;
}
}
Ok(removed)
}
}
#[inline]
fn escape_json_str(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
c => result.push(c),
}
}
result
}
fn time_to_iso8601(secs: u64) -> String {
const SECS_PER_DAY: u64 = 86400;
const DAYS_PER_YEAR: u64 = 365;
const DAYS_PER_4_YEARS: u64 = 1461;
let days = secs / SECS_PER_DAY;
let day_secs = secs % SECS_PER_DAY;
let hours = day_secs / 3600;
let minutes = (day_secs % 3600) / 60;
let seconds = day_secs % 60;
let mut year = 1970u64;
let mut remaining_days = days;
let four_year_cycles = remaining_days / DAYS_PER_4_YEARS;
year += four_year_cycles * 4;
remaining_days %= DAYS_PER_4_YEARS;
while remaining_days >= DAYS_PER_YEAR {
let is_leap = (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0);
let days_this_year = if is_leap { 366 } else { 365 };
if remaining_days < days_this_year {
break;
}
remaining_days -= days_this_year;
year += 1;
}
let month = (remaining_days / 30).min(11) + 1;
let day = (remaining_days % 30) + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_to_json() {
let snapshot = Snapshot::new(100);
let json = snapshot.to_json();
assert!(json.contains("\"version\": 1"));
assert!(json.contains("\"frame\": 100"));
}
#[test]
fn test_snapshot_config_builder() {
let config = SnapshotConfig::default()
.with_directory("custom/path")
.with_max_snapshots(50)
.with_auto_emit(true);
assert_eq!(config.directory, PathBuf::from("custom/path"));
assert_eq!(config.max_snapshots, 50);
assert!(config.auto_emit);
}
#[test]
fn test_json_escaping_quotes() {
let escaped = escape_json_str("hello\"world");
assert_eq!(escaped, "hello\\\"world");
}
#[test]
fn test_json_escaping_backslash() {
let escaped = escape_json_str("path\\to\\file");
assert_eq!(escaped, "path\\\\to\\\\file");
}
#[test]
fn test_json_escaping_newlines() {
let escaped = escape_json_str("line1\nline2\rline3");
assert_eq!(escaped, "line1\\nline2\\rline3");
}
#[test]
fn test_json_escaping_tabs() {
let escaped = escape_json_str("col1\tcol2");
assert_eq!(escaped, "col1\\tcol2");
}
#[test]
fn test_snapshot_with_special_chars() {
let mut snapshot = Snapshot::new(1);
snapshot.tags.push(TagSnapshot {
path: "physics\"collision".to_string(),
thread: "main\nthread".to_string(),
alloc_kind: "frame".to_string(),
alloc_count: 10,
bytes: 1024,
avg_lifetime_frames: 1.0,
promotion_rate: 0.0,
diagnostics: vec!["test\"diag".to_string()],
});
let json = snapshot.to_json();
assert!(json.contains("physics\\\"collision"));
assert!(json.contains("main\\nthread"));
assert!(json.contains("test\\\"diag"));
}
#[test]
fn test_snapshot_with_duration() {
let snapshot = Snapshot::new(100)
.with_duration(Duration::from_micros(16667));
assert_eq!(snapshot.duration_us, 16667);
let json = snapshot.to_json();
assert!(json.contains("\"duration_us\": 16667"));
}
}