1#![cfg(feature = "diagnostics")]
15
16use crate::channel::{TOTAL_MESSAGES_RECEIVED, TOTAL_MESSAGES_SENT};
17use crate::memory_stats::memory_registry;
18use crate::scheduler::{PEAK_STRANDS, TOTAL_COMPLETED, TOTAL_SPAWNED, scheduler_elapsed};
19use std::io::Write;
20use std::sync::OnceLock;
21use std::sync::atomic::Ordering;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ReportFormat {
30 Human,
31 Json,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReportDestination {
37 Stderr,
38 File(String),
39}
40
41#[derive(Debug, Clone)]
43pub struct ReportConfig {
44 pub format: ReportFormat,
45 pub destination: ReportDestination,
46 pub include_words: bool,
48}
49
50impl ReportConfig {
51 pub fn from_env() -> Option<Self> {
53 let val = std::env::var("SEQ_REPORT").ok()?;
54 if val.is_empty() {
55 return None;
56 }
57
58 match val.as_str() {
59 "0" => None,
60 "1" => Some(ReportConfig {
61 format: ReportFormat::Human,
62 destination: ReportDestination::Stderr,
63 include_words: false,
64 }),
65 "words" => Some(ReportConfig {
66 format: ReportFormat::Human,
67 destination: ReportDestination::Stderr,
68 include_words: true,
69 }),
70 "json" => Some(ReportConfig {
71 format: ReportFormat::Json,
72 destination: ReportDestination::Stderr,
73 include_words: false,
74 }),
75 s if s.starts_with("json:") => {
76 let path = s[5..].to_string();
77 Some(ReportConfig {
78 format: ReportFormat::Json,
79 destination: ReportDestination::File(path),
80 include_words: false,
81 })
82 }
83 _ => {
84 eprintln!("Warning: SEQ_REPORT='{}' not recognized, ignoring", val);
85 None
86 }
87 }
88 }
89}
90
91static REPORT_CONFIG: OnceLock<Option<ReportConfig>> = OnceLock::new();
92
93fn get_report_config() -> &'static Option<ReportConfig> {
94 REPORT_CONFIG.get_or_init(ReportConfig::from_env)
95}
96
97#[derive(Debug)]
103pub struct ReportData {
104 pub wall_clock_ms: u64,
105 pub total_spawned: u64,
106 pub total_completed: u64,
107 pub peak_strands: usize,
108 pub active_threads: usize,
109 pub total_arena_bytes: u64,
110 pub total_peak_arena_bytes: u64,
111 pub messages_sent: u64,
112 pub messages_received: u64,
113 pub word_counts: Option<Vec<(String, u64)>>,
114}
115
116fn collect_report_data(include_words: bool) -> ReportData {
118 let wall_clock_ms = scheduler_elapsed()
119 .map(|d| d.as_millis() as u64)
120 .unwrap_or(0);
121
122 let mem_stats = memory_registry().aggregate_stats();
123
124 let word_counts = if include_words {
125 read_word_counts()
126 } else {
127 None
128 };
129
130 ReportData {
131 wall_clock_ms,
132 total_spawned: TOTAL_SPAWNED.load(Ordering::Relaxed),
133 total_completed: TOTAL_COMPLETED.load(Ordering::Relaxed),
134 peak_strands: PEAK_STRANDS.load(Ordering::Relaxed),
135 active_threads: mem_stats.active_threads,
136 total_arena_bytes: mem_stats.total_arena_bytes,
137 total_peak_arena_bytes: mem_stats.total_peak_arena_bytes,
138 messages_sent: TOTAL_MESSAGES_SENT.load(Ordering::Relaxed),
139 messages_received: TOTAL_MESSAGES_RECEIVED.load(Ordering::Relaxed),
140 word_counts,
141 }
142}
143
144fn format_human(data: &ReportData) -> String {
149 let mut out = String::new();
150 out.push_str("=== SEQ REPORT ===\n");
151 out.push_str(&format!("Wall clock: {} ms\n", data.wall_clock_ms));
152 out.push_str(&format!("Strands spawned: {}\n", data.total_spawned));
153 out.push_str(&format!("Strands done: {}\n", data.total_completed));
154 out.push_str(&format!("Peak strands: {}\n", data.peak_strands));
155 out.push_str(&format!("Worker threads: {}\n", data.active_threads));
156 out.push_str(&format!(
157 "Arena current: {} bytes\n",
158 data.total_arena_bytes
159 ));
160 out.push_str(&format!(
161 "Arena peak: {} bytes\n",
162 data.total_peak_arena_bytes
163 ));
164 out.push_str(&format!("Messages sent: {}\n", data.messages_sent));
165 out.push_str(&format!("Messages recv: {}\n", data.messages_received));
166
167 if let Some(ref counts) = data.word_counts {
168 out.push_str("\n--- Word Call Counts ---\n");
169 for (name, count) in counts {
170 out.push_str(&format!(" {:30} {}\n", name, count));
171 }
172 }
173
174 out.push_str("==================\n");
175 out
176}
177
178#[cfg(feature = "report-json")]
179fn format_json(data: &ReportData) -> String {
180 let mut map = serde_json::Map::new();
181 map.insert(
182 "wall_clock_ms".into(),
183 serde_json::Value::Number(data.wall_clock_ms.into()),
184 );
185 map.insert(
186 "strands_spawned".into(),
187 serde_json::Value::Number(data.total_spawned.into()),
188 );
189 map.insert(
190 "strands_completed".into(),
191 serde_json::Value::Number(data.total_completed.into()),
192 );
193 map.insert(
194 "peak_strands".into(),
195 serde_json::Value::Number((data.peak_strands as u64).into()),
196 );
197 map.insert(
198 "worker_threads".into(),
199 serde_json::Value::Number((data.active_threads as u64).into()),
200 );
201 map.insert(
202 "arena_bytes".into(),
203 serde_json::Value::Number(data.total_arena_bytes.into()),
204 );
205 map.insert(
206 "arena_peak_bytes".into(),
207 serde_json::Value::Number(data.total_peak_arena_bytes.into()),
208 );
209 map.insert(
210 "messages_sent".into(),
211 serde_json::Value::Number(data.messages_sent.into()),
212 );
213 map.insert(
214 "messages_received".into(),
215 serde_json::Value::Number(data.messages_received.into()),
216 );
217
218 if let Some(ref counts) = data.word_counts {
219 let word_map: serde_json::Map<String, serde_json::Value> = counts
220 .iter()
221 .map(|(name, count)| (name.clone(), serde_json::Value::Number((*count).into())))
222 .collect();
223 map.insert("word_counts".into(), serde_json::Value::Object(word_map));
224 }
225
226 let obj = serde_json::Value::Object(map);
227 serde_json::to_string(&obj).unwrap_or_else(|_| "{}".to_string())
228}
229
230#[cfg(not(feature = "report-json"))]
231fn format_json(_data: &ReportData) -> String {
232 eprintln!(
233 "Warning: SEQ_REPORT=json requires the 'report-json' feature. Falling back to human format."
234 );
235 format_human(_data)
236}
237
238struct WordCountData {
244 counters: *const u64,
245 names: *const *const u8,
246 count: usize,
247}
248
249unsafe impl Send for WordCountData {}
251unsafe impl Sync for WordCountData {}
252
253static WORD_COUNT_DATA: OnceLock<WordCountData> = OnceLock::new();
254
255fn read_word_counts() -> Option<Vec<(String, u64)>> {
256 let data = WORD_COUNT_DATA.get()?;
257 let mut counts = Vec::with_capacity(data.count);
258
259 unsafe {
260 for i in 0..data.count {
261 let counter_val = std::ptr::read_volatile(data.counters.add(i));
262 let name_ptr = *data.names.add(i);
263 let name = std::ffi::CStr::from_ptr(name_ptr as *const i8)
264 .to_string_lossy()
265 .into_owned();
266 counts.push((name, counter_val));
267 }
268 }
269
270 counts.sort_by(|a, b| b.1.cmp(&a.1));
272 Some(counts)
273}
274
275fn emit_report() {
280 let config = match get_report_config() {
281 Some(c) => c,
282 None => return,
283 };
284
285 let data = collect_report_data(config.include_words);
286
287 let output = match config.format {
288 ReportFormat::Human => format_human(&data),
289 ReportFormat::Json => format_json(&data),
290 };
291
292 match &config.destination {
293 ReportDestination::Stderr => {
294 let _ = std::io::stderr().write_all(output.as_bytes());
295 }
296 ReportDestination::File(path) => {
297 if let Ok(mut f) = std::fs::File::create(path) {
298 let _ = f.write_all(output.as_bytes());
299 } else {
300 eprintln!("Warning: could not write report to {}", path);
301 let _ = std::io::stderr().write_all(output.as_bytes());
302 }
303 }
304 }
305}
306
307#[unsafe(no_mangle)]
316pub unsafe extern "C" fn patch_seq_report() {
317 emit_report();
318}
319
320#[unsafe(no_mangle)]
327pub unsafe extern "C" fn patch_seq_report_init(
328 counters: *const u64,
329 names: *const *const u8,
330 count: i64,
331) {
332 if counters.is_null() || names.is_null() || count <= 0 {
333 return;
334 }
335 let _ = WORD_COUNT_DATA.set(WordCountData {
336 counters,
337 names,
338 count: count as usize,
339 });
340}
341
342#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn test_config_parse_none() {
352 assert!(ReportConfig::from_env().is_none() || ReportConfig::from_env().is_some());
355 }
356
357 #[test]
358 fn test_config_parse_variants() {
359 let test_cases = vec![
361 ("0", None),
362 (
363 "1",
364 Some((ReportFormat::Human, ReportDestination::Stderr, false)),
365 ),
366 (
367 "words",
368 Some((ReportFormat::Human, ReportDestination::Stderr, true)),
369 ),
370 (
371 "json",
372 Some((ReportFormat::Json, ReportDestination::Stderr, false)),
373 ),
374 (
375 "json:/tmp/report.json",
376 Some((
377 ReportFormat::Json,
378 ReportDestination::File("/tmp/report.json".to_string()),
379 false,
380 )),
381 ),
382 ];
383
384 for (input, expected) in test_cases {
385 let result = match input {
386 "0" => None,
387 "1" => Some(ReportConfig {
388 format: ReportFormat::Human,
389 destination: ReportDestination::Stderr,
390 include_words: false,
391 }),
392 "words" => Some(ReportConfig {
393 format: ReportFormat::Human,
394 destination: ReportDestination::Stderr,
395 include_words: true,
396 }),
397 "json" => Some(ReportConfig {
398 format: ReportFormat::Json,
399 destination: ReportDestination::Stderr,
400 include_words: false,
401 }),
402 s if s.starts_with("json:") => Some(ReportConfig {
403 format: ReportFormat::Json,
404 destination: ReportDestination::File(s[5..].to_string()),
405 include_words: false,
406 }),
407 _ => None,
408 };
409
410 match (result, expected) {
411 (None, None) => {}
412 (Some(r), Some((fmt, dest, words))) => {
413 assert_eq!(r.format, fmt, "format mismatch for input '{}'", input);
414 assert_eq!(
415 r.destination, dest,
416 "destination mismatch for input '{}'",
417 input
418 );
419 assert_eq!(
420 r.include_words, words,
421 "include_words mismatch for input '{}'",
422 input
423 );
424 }
425 _ => panic!("Mismatch for input '{}'", input),
426 }
427 }
428 }
429
430 #[test]
431 fn test_collect_report_data() {
432 let data = collect_report_data(false);
433 assert!(data.wall_clock_ms < 1_000_000_000); assert!(data.peak_strands < 1_000_000);
436 assert!(data.word_counts.is_none());
437 }
438
439 #[test]
440 fn test_format_human() {
441 let data = ReportData {
442 wall_clock_ms: 42,
443 total_spawned: 10,
444 total_completed: 9,
445 peak_strands: 5,
446 active_threads: 2,
447 total_arena_bytes: 1024,
448 total_peak_arena_bytes: 2048,
449 messages_sent: 100,
450 messages_received: 99,
451 word_counts: None,
452 };
453 let output = format_human(&data);
454 assert!(output.contains("SEQ REPORT"));
455 assert!(output.contains("42 ms"));
456 assert!(output.contains("Strands spawned: 10"));
457 assert!(output.contains("Arena peak: 2048 bytes"));
458 }
459
460 #[test]
461 fn test_format_human_with_word_counts() {
462 let data = ReportData {
463 wall_clock_ms: 100,
464 total_spawned: 1,
465 total_completed: 1,
466 peak_strands: 1,
467 active_threads: 1,
468 total_arena_bytes: 0,
469 total_peak_arena_bytes: 0,
470 messages_sent: 0,
471 messages_received: 0,
472 word_counts: Some(vec![("main".to_string(), 1), ("helper".to_string(), 42)]),
473 };
474 let output = format_human(&data);
475 assert!(output.contains("Word Call Counts"));
476 assert!(output.contains("main"));
477 assert!(output.contains("helper"));
478 }
479
480 #[cfg(feature = "report-json")]
481 #[test]
482 fn test_format_json() {
483 let data = ReportData {
484 wall_clock_ms: 42,
485 total_spawned: 10,
486 total_completed: 9,
487 peak_strands: 5,
488 active_threads: 2,
489 total_arena_bytes: 1024,
490 total_peak_arena_bytes: 2048,
491 messages_sent: 100,
492 messages_received: 99,
493 word_counts: None,
494 };
495 let output = format_json(&data);
496 assert!(output.contains("\"wall_clock_ms\":42"));
497 assert!(output.contains("\"strands_spawned\":10"));
498 assert!(output.contains("\"arena_peak_bytes\":2048"));
499 }
500
501 #[test]
502 fn test_emit_report_noop_when_disabled() {
503 emit_report();
505 }
507}