1#![allow(missing_docs)]
4use std::collections::{HashMap, HashSet, VecDeque};
25use std::path::Path;
26
27use serde_json::{json, Value};
28
29const DEFAULT_MAX_RETAINERS: usize = 200;
31const DEFAULT_MAX_EDGES: usize = 200;
32const DEFAULT_MAX_PATHS: usize = 32;
33const DEFAULT_MAX_PATH_DEPTH: usize = 8;
34const DEFAULT_MAX_CLASS_NODES: usize = 500;
35const MAX_HEAP_SNAPSHOT_BYTES: u64 = 512 * 1024 * 1024;
38
39#[derive(Debug, Clone)]
40struct NodeRec {
41 index: usize,
42 type_name: String,
43 name: String,
44 id: u64,
45 self_size: u64,
46 edge_count: usize,
47 detachedness: Option<u64>,
49}
50
51#[derive(Debug, Clone)]
52struct EdgeRec {
53 from: usize,
54 to: usize,
55 type_name: String,
56 name: String,
57}
58
59#[derive(Debug)]
60struct SnapshotGraph {
61 path: String,
62 bytes: u64,
63 nodes: Vec<NodeRec>,
64 out_edges: Vec<Vec<EdgeRec>>,
66 in_edges: Vec<Vec<EdgeRec>>,
68 id_to_index: HashMap<u64, usize>,
70 class_counts: HashMap<String, u64>,
71 class_self_sizes: HashMap<String, u64>,
72 class_to_nodes: HashMap<String, Vec<usize>>,
74 node_fields: Vec<String>,
75 edge_fields: Vec<String>,
76 node_types: Vec<String>,
77 edge_types: Vec<String>,
78 string_count: u64,
79 strings: Vec<String>,
80}
81
82impl SnapshotGraph {
83 fn load(path: &Path) -> Result<Self, String> {
84 let meta = std::fs::metadata(path).map_err(|e| format!("heap file: {e}"))?;
85 if meta.len() > MAX_HEAP_SNAPSHOT_BYTES {
86 return Err(format!(
87 "heap snapshot too large: {} bytes > {} budget (use a smaller capture)",
88 meta.len(),
89 MAX_HEAP_SNAPSHOT_BYTES
90 ));
91 }
92 let mut raw = String::new();
94 raw.try_reserve_exact(meta.len() as usize)
95 .map_err(|e| format!("heap allocate failed ({e}); file may exceed host RAM"))?;
96 let file = std::fs::File::open(path).map_err(|e| format!("heap open: {e}"))?;
97 use std::io::Read;
98 std::io::BufReader::new(file)
99 .read_to_string(&mut raw)
100 .map_err(|e| format!("heap read: {e}"))?;
101 let v: Value = crate::json_util::from_str(&raw)
102 .map_err(|e| format!("heap parse JSON: {e}"))?;
103 drop(raw);
105
106 let snapshot = v.get("snapshot").cloned().unwrap_or(Value::Null);
107 let meta_obj = snapshot.get("meta").cloned().unwrap_or(Value::Null);
108
109 let node_fields = string_list(&meta_obj, "node_fields");
110 let edge_fields = string_list(&meta_obj, "edge_fields");
111 let node_types = nested_string_list(&meta_obj, "node_types");
112 let edge_types = nested_string_list(&meta_obj, "edge_types");
113
114 let nodes_flat = i64_list(&v, "nodes");
115 let edges_flat = i64_list(&v, "edges");
116 let strings = string_array(&v, "strings");
117
118 let node_stride = node_fields.len().max(1);
119 let edge_stride = edge_fields.len().max(1);
120
121 let type_idx = field_index(&node_fields, "type").unwrap_or(0);
122 let name_idx = field_index(&node_fields, "name");
123 let id_idx = field_index(&node_fields, "id");
124 let self_idx = field_index(&node_fields, "self_size");
125 let edge_count_idx = field_index(&node_fields, "edge_count");
126 let detached_idx = field_index(&node_fields, "detachedness");
127
128 let edge_type_idx = field_index(&edge_fields, "type").unwrap_or(0);
129 let edge_name_idx = field_index(&edge_fields, "name_or_index");
130 let to_node_idx =
131 field_index(&edge_fields, "to_node").unwrap_or(edge_fields.len().saturating_sub(1));
132
133 let approx_nodes = nodes_flat.len() / node_stride.max(1);
135 let n_full = approx_nodes;
138 let materialize = |index: usize| -> Option<NodeRec> {
139 let base = index * node_stride;
140 if base + node_stride > nodes_flat.len() {
141 return None;
142 }
143 let chunk = &nodes_flat[base..base + node_stride];
144 let type_id = chunk[type_idx].max(0) as usize;
145 let type_name = node_types
146 .get(type_id)
147 .cloned()
148 .unwrap_or_else(|| format!("type_{type_id}"));
149 let name = name_idx
150 .and_then(|ni| {
151 let sid = chunk[ni].max(0) as usize;
152 strings.get(sid).cloned().filter(|s| !s.is_empty())
153 })
154 .unwrap_or_else(|| type_name.clone());
155 let id = id_idx
156 .map(|i| chunk[i].max(0) as u64)
157 .unwrap_or(index as u64);
158 let self_size = self_idx.map(|i| chunk[i].max(0) as u64).unwrap_or(0);
159 let edge_count = edge_count_idx
160 .map(|i| chunk[i].max(0) as usize)
161 .unwrap_or(0);
162 let detachedness = detached_idx.map(|i| chunk[i].max(0) as u64);
163 Some(NodeRec {
164 index,
165 type_name,
166 name,
167 id,
168 self_size,
169 edge_count,
170 detachedness,
171 })
172 };
173 let nodes: Vec<NodeRec> = if n_full < crate::concurrency::CPU_MAP_THRESHOLD {
174 (0..n_full).filter_map(materialize).collect()
175 } else {
176 crate::concurrency::install_rayon_pool_once();
177 use rayon::prelude::*;
178 (0..n_full)
179 .into_par_iter()
180 .filter_map(materialize)
181 .collect()
182 };
183 let mut class_counts: HashMap<String, u64> = HashMap::with_capacity(64);
184 let mut class_self_sizes: HashMap<String, u64> = HashMap::with_capacity(64);
185 let mut class_to_nodes: HashMap<String, Vec<usize>> = HashMap::with_capacity(64);
186 let mut id_to_index: HashMap<u64, usize> = HashMap::new();
187 id_to_index
188 .try_reserve(nodes.len())
189 .map_err(|e| format!("heap id map reserve failed: {e}"))?;
190 for node in &nodes {
191 *class_counts.entry(node.name.clone()).or_insert(0) += 1;
192 *class_self_sizes.entry(node.name.clone()).or_insert(0) += node.self_size;
193 class_to_nodes
194 .entry(node.name.clone())
195 .or_default()
196 .push(node.index);
197 id_to_index.insert(node.id, node.index);
198 }
199
200 let n = nodes.len();
201 let mut out_edges: Vec<Vec<EdgeRec>> = vec![Vec::new(); n];
202 let mut in_edges: Vec<Vec<EdgeRec>> = vec![Vec::new(); n];
203
204 let mut edge_cursor = 0usize;
205 for (from, node) in nodes.iter().enumerate() {
206 for _ in 0..node.edge_count {
207 let base = edge_cursor * edge_stride;
208 if base + edge_stride > edges_flat.len() {
209 break;
210 }
211 let etype_id = edges_flat[base + edge_type_idx].max(0) as usize;
212 let type_name = edge_types
213 .get(etype_id)
214 .cloned()
215 .unwrap_or_else(|| format!("edge_type_{etype_id}"));
216 let ename = edge_name_idx
217 .map(|ni| {
218 let raw = edges_flat[base + ni];
219 if raw >= 0 {
221 let sid = raw as usize;
222 strings
223 .get(sid)
224 .cloned()
225 .filter(|s| !s.is_empty())
226 .unwrap_or_else(|| raw.to_string())
227 } else {
228 raw.to_string()
229 }
230 })
231 .unwrap_or_default();
232 let to_flat = edges_flat[base + to_node_idx].max(0) as usize;
233 let to = to_flat / node_stride;
234 if to < n {
235 let e = EdgeRec {
236 from,
237 to,
238 type_name,
239 name: ename,
240 };
241 out_edges[from].push(e.clone());
242 in_edges[to].push(e);
243 }
244 edge_cursor += 1;
245 }
246 }
247
248 Ok(Self {
249 path: path.to_string_lossy().into_owned(),
250 bytes: meta.len(),
251 nodes,
252 out_edges,
253 in_edges,
254 id_to_index,
255 class_counts,
256 class_self_sizes,
257 class_to_nodes,
258 node_fields,
259 edge_fields,
260 node_types,
261 edge_types,
262 string_count: strings.len() as u64,
263 strings,
264 })
265 }
266
267 fn resolve_node(&self, node_id_or_index: u64) -> Result<usize, String> {
268 if let Some(&idx) = self.id_to_index.get(&node_id_or_index) {
269 return Ok(idx);
270 }
271 let idx = node_id_or_index as usize;
272 if idx < self.nodes.len() {
273 return Ok(idx);
274 }
275 Err(format!(
276 "node id/index {node_id_or_index} not found (node_count={})",
277 self.nodes.len()
278 ))
279 }
280
281 fn node_json(&self, idx: usize) -> Value {
282 let n = &self.nodes[idx];
283 json!({
284 "index": n.index,
285 "id": n.id,
286 "name": n.name,
287 "type": n.type_name,
288 "self_size": n.self_size,
289 "edge_count": n.edge_count,
290 "retainer_count": self.in_edges[idx].len(),
291 })
292 }
293
294 fn pick_root(&self) -> usize {
295 if let Some((i, _)) = self.nodes.iter().enumerate().find(|(_, n)| {
297 n.name.contains("GC roots") || n.type_name == "synthetic" || n.name == "(GC roots)"
298 }) {
299 return i;
300 }
301 self.nodes
302 .iter()
303 .enumerate()
304 .find(|(i, _)| self.in_edges[*i].is_empty())
305 .map(|(i, _)| i)
306 .unwrap_or(0)
307 }
308
309 fn distances_from_root(&self) -> Vec<Option<u64>> {
311 let n = self.nodes.len();
312 let mut dist = vec![None; n];
313 if n == 0 {
314 return dist;
315 }
316 let root = self.pick_root();
317 let mut q = VecDeque::new();
318 dist[root] = Some(0);
319 q.push_back(root);
320 while let Some(u) = q.pop_front() {
321 let d = dist[u].unwrap_or(0);
322 for e in &self.out_edges[u] {
323 if dist[e.to].is_none() {
324 dist[e.to] = Some(d + 1);
325 q.push_back(e.to);
326 }
327 }
328 }
329 dist
330 }
331
332 fn retained_sizes(&self) -> Vec<u64> {
334 let n = self.nodes.len();
335 let mut retained = vec![0u64; n];
336 if n == 0 {
337 return retained;
338 }
339 let idom = self.compute_idom();
340 let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
341 for (i, &dom) in idom.iter().enumerate() {
342 if let Some(d) = dom {
343 if d != i {
344 children[d].push(i);
345 }
346 }
347 }
348 fn dfs(
350 u: usize,
351 children: &[Vec<usize>],
352 nodes: &[NodeRec],
353 retained: &mut [u64],
354 seen: &mut [bool],
355 ) {
356 if seen[u] {
357 return;
358 }
359 seen[u] = true;
360 let mut sum = nodes[u].self_size;
361 for &c in &children[u] {
362 dfs(c, children, nodes, retained, seen);
363 sum = sum.saturating_add(retained[c]);
364 }
365 retained[u] = sum;
366 }
367 let mut seen = vec![false; n];
368 for i in 0..n {
369 if !seen[i] {
370 let mut r = i;
372 let mut guard = 0;
373 while let Some(d) = idom[r] {
374 if d == r || guard > n {
375 break;
376 }
377 r = d;
378 guard += 1;
379 }
380 dfs(r, &children, &self.nodes, &mut retained, &mut seen);
381 }
382 }
383 for (i, ret) in retained.iter_mut().enumerate() {
384 if *ret == 0 {
385 *ret = self.nodes[i].self_size;
386 }
387 }
388 retained
389 }
390
391 fn detachedness_label(raw: Option<u64>) -> String {
392 match raw {
393 None => "unknown".into(),
394 Some(0) => "attached".into(),
395 Some(1) => "detached".into(),
396 Some(2) => "unknown".into(),
397 Some(v) => format!("code_{v}"),
398 }
399 }
400
401 fn object_info_json(&self, idx: usize) -> Value {
403 let n = &self.nodes[idx];
404 let distances = self.distances_from_root();
405 let retained = self.retained_sizes();
406 let distance = distances[idx];
407 json!({
408 "index": n.index,
409 "id": n.id,
410 "name": n.name,
411 "type": n.type_name,
412 "self_size": n.self_size,
413 "retained_size": retained[idx],
414 "distance": distance,
415 "edge_count": n.edge_count,
416 "retainer_count": self.in_edges[idx].len(),
417 "detachedness": Self::detachedness_label(n.detachedness),
418 })
419 }
420
421 fn edge_json(&self, e: &EdgeRec) -> Value {
422 let from = &self.nodes[e.from];
423 let to = &self.nodes[e.to];
424 json!({
425 "type": e.type_name,
426 "name": e.name,
427 "from_id": from.id,
428 "from_name": from.name,
429 "to_id": to.id,
430 "to_name": to.name,
431 })
432 }
433
434 fn compute_idom(&self) -> Vec<Option<usize>> {
436 let n = self.nodes.len();
437 if n == 0 {
438 return Vec::new();
439 }
440
441 let mut roots: Vec<usize> = self
443 .nodes
444 .iter()
445 .enumerate()
446 .filter(|(_, node)| {
447 node.type_name == "synthetic"
448 || node.name.contains("GC roots")
449 || node.name == "(GC roots)"
450 })
451 .map(|(i, _)| i)
452 .collect();
453 if roots.is_empty() {
454 roots = self
455 .nodes
456 .iter()
457 .enumerate()
458 .filter(|(i, _)| self.in_edges[*i].is_empty())
459 .map(|(i, _)| i)
460 .collect();
461 }
462 if roots.is_empty() {
463 roots.push(0);
464 }
465 let root = roots[0];
466
467 let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
469 for (to, edges) in self.in_edges.iter().enumerate() {
470 if to == root {
471 continue;
472 }
473 for e in edges {
474 if e.from < n {
475 preds[to].push(e.from);
476 }
477 }
478 }
479
480 let mut rpo = Vec::with_capacity(n);
482 let mut visited = vec![false; n];
483 let mut stack = vec![(root, false)];
484 while let Some((u, expanded)) = stack.pop() {
485 if expanded {
486 rpo.push(u);
487 continue;
488 }
489 if visited[u] {
490 continue;
491 }
492 visited[u] = true;
493 stack.push((u, true));
494 for e in &self.out_edges[u] {
495 if e.to < n && !visited[e.to] {
496 stack.push((e.to, false));
497 }
498 }
499 }
500 for (i, was_visited) in visited.iter().enumerate() {
502 if !was_visited {
503 rpo.push(i);
504 }
505 }
506 rpo.reverse(); let mut idom: Vec<Option<usize>> = vec![None; n];
509 idom[root] = Some(root);
510
511 let mut rpo_index = vec![0usize; n];
513 for (i, &u) in rpo.iter().enumerate() {
514 rpo_index[u] = i;
515 }
516
517 let intersect =
518 |mut b1: usize, mut b2: usize, idom: &[Option<usize>], rpo_index: &[usize]| {
519 while b1 != b2 {
520 while rpo_index[b1] > rpo_index[b2] {
521 b1 = idom[b1].unwrap_or(b1);
522 }
523 while rpo_index[b2] > rpo_index[b1] {
524 b2 = idom[b2].unwrap_or(b2);
525 }
526 }
527 b1
528 };
529
530 let mut changed = true;
531 let mut iterations = 0usize;
532 while changed && iterations < n.saturating_mul(2).max(8) {
533 changed = false;
534 iterations += 1;
535 for &u in &rpo {
536 if u == root {
537 continue;
538 }
539 let mut new_idom: Option<usize> = None;
540 for &p in &preds[u] {
541 if idom[p].is_none() {
542 continue;
543 }
544 new_idom = Some(match new_idom {
545 None => p,
546 Some(cur) => intersect(p, cur, &idom, &rpo_index),
547 });
548 }
549 if new_idom.is_some() && new_idom != idom[u] {
550 idom[u] = new_idom;
551 changed = true;
552 }
553 }
554 }
555 idom
556 }
557
558 fn dominator_chain(&self, idx: usize) -> Vec<Value> {
559 let idom = self.compute_idom();
560 let mut chain = Vec::new();
561 let mut seen = HashSet::new();
562 let mut cur = idx;
563 for _ in 0..self.nodes.len().saturating_add(1) {
564 if !seen.insert(cur) {
565 break;
566 }
567 chain.push(self.node_json(cur));
568 match idom.get(cur).copied().flatten() {
569 Some(d) if d != cur => cur = d,
570 _ => break,
571 }
572 }
573 chain.reverse(); chain
575 }
576
577 fn retaining_paths(
578 &self,
579 idx: usize,
580 max_depth: usize,
581 max_paths: usize,
582 ) -> (Vec<Value>, bool) {
583 let mut paths: Vec<Value> = Vec::new();
585 let mut limits = false;
586 let mut q: VecDeque<(usize, Vec<usize>)> = VecDeque::new();
588 q.push_back((idx, vec![idx]));
589 let mut visited_states = 0usize;
590 const MAX_STATES: usize = 50_000;
591
592 while let Some((u, path)) = q.pop_front() {
593 visited_states += 1;
594 if visited_states > MAX_STATES {
595 limits = true;
596 break;
597 }
598 if paths.len() >= max_paths {
599 limits = true;
600 break;
601 }
602 let is_root = self.in_edges[u].is_empty()
603 || self.nodes[u].type_name == "synthetic"
604 || self.nodes[u].name.contains("GC roots");
605 if (is_root && path.len() > 1) || path.len() > max_depth {
606 let nodes_json: Vec<Value> =
607 path.iter().rev().map(|&i| self.node_json(i)).collect();
608 if path.len() > max_depth && !is_root {
610 paths.push(json!({
612 "nodes": nodes_json,
613 "depth": path.len().saturating_sub(1),
614 "reached_root": false,
615 }));
616 } else {
617 paths.push(json!({
618 "nodes": nodes_json,
619 "depth": path.len().saturating_sub(1),
620 "reached_root": is_root,
621 }));
622 }
623 continue;
624 }
625 if self.in_edges[u].is_empty() {
626 let nodes_json: Vec<Value> =
627 path.iter().rev().map(|&i| self.node_json(i)).collect();
628 paths.push(json!({
629 "nodes": nodes_json,
630 "depth": path.len().saturating_sub(1),
631 "reached_root": true,
632 }));
633 continue;
634 }
635 for e in &self.in_edges[u] {
636 if path.contains(&e.from) {
637 continue;
638 }
639 if path.len() > max_depth {
640 limits = true;
641 continue;
642 }
643 let mut next = path.clone();
644 next.push(e.from);
645 q.push_back((e.from, next));
646 }
647 }
648 (paths, limits)
649 }
650}
651
652fn field_index(fields: &[String], name: &str) -> Option<usize> {
653 fields.iter().position(|f| f == name)
654}
655
656fn string_list(meta: &Value, key: &str) -> Vec<String> {
657 meta.get(key)
658 .and_then(|v| v.as_array())
659 .map(|a| {
660 a.iter()
661 .filter_map(|x| x.as_str().map(|s| s.to_string()))
662 .collect()
663 })
664 .unwrap_or_default()
665}
666
667fn nested_string_list(meta: &Value, key: &str) -> Vec<String> {
668 meta.get(key)
669 .and_then(|v| v.as_array())
670 .and_then(|a| a.first())
671 .and_then(|v| v.as_array())
672 .map(|a| {
673 a.iter()
674 .filter_map(|x| x.as_str().map(|s| s.to_string()))
675 .collect()
676 })
677 .unwrap_or_else(|| string_list(meta, key))
678}
679
680fn i64_list(root: &Value, key: &str) -> Vec<i64> {
681 root.get(key)
682 .and_then(|v| v.as_array())
683 .map(|a| a.iter().filter_map(|x| x.as_i64()).collect())
684 .unwrap_or_default()
685}
686
687fn string_array(root: &Value, key: &str) -> Vec<String> {
688 root.get(key)
689 .and_then(|v| v.as_array())
690 .map(|a| {
691 a.iter()
692 .map(|x| x.as_str().unwrap_or("").to_string())
693 .collect()
694 })
695 .unwrap_or_default()
696}
697
698pub fn summarize(path: &Path) -> Result<Value, String> {
699 let s = SnapshotGraph::load(path)?;
700 let mut top: Vec<(String, u64)> = s.class_counts.into_iter().collect();
701 crate::concurrency::sort_by_key_cpu(&mut top, |b| std::cmp::Reverse(b.1));
703 top.truncate(20);
704 Ok(json!({
705 "path": s.path,
706 "bytes": s.bytes,
707 "exists": true,
708 "node_count": s.nodes.len() as u64,
709 "edge_count": s.out_edges.iter().map(|e| e.len() as u64).sum::<u64>(),
710 "string_count": s.string_count,
711 "top_classes": top.into_iter().map(|(name, count)| json!({
712 "name": name,
713 "count": count,
714 })).collect::<Vec<_>>(),
715 "offline": true,
716 }))
717}
718
719pub fn details(path: &Path) -> Result<Value, String> {
720 let s = SnapshotGraph::load(path)?;
721 let mut classes: Vec<Value> = s
722 .class_counts
723 .iter()
724 .map(|(name, count)| {
725 json!({
726 "name": name,
727 "count": count,
728 "self_size": s.class_self_sizes.get(name).copied().unwrap_or(0),
729 })
730 })
731 .collect();
732 crate::concurrency::sort_by_cpu(&mut classes, |a, b| {
733 b.get("count")
734 .and_then(|v| v.as_u64())
735 .cmp(&a.get("count").and_then(|v| v.as_u64()))
736 });
737 Ok(json!({
738 "path": s.path,
739 "bytes": s.bytes,
740 "node_count": s.nodes.len() as u64,
741 "edge_count": s.out_edges.iter().map(|e| e.len() as u64).sum::<u64>(),
742 "string_count": s.string_count,
743 "node_fields": s.node_fields,
744 "edge_fields": s.edge_fields,
745 "node_types": s.node_types,
746 "edge_types": s.edge_types,
747 "classes": classes,
748 "offline": true,
749 }))
750}
751
752pub fn compare(base: &Path, current: &Path) -> Result<Value, String> {
753 let b = SnapshotGraph::load(base)?;
754 let c = SnapshotGraph::load(current)?;
755 let b_edges: u64 = b.out_edges.iter().map(|e| e.len() as u64).sum();
756 let c_edges: u64 = c.out_edges.iter().map(|e| e.len() as u64).sum();
757 Ok(json!({
758 "base": {
759 "path": b.path,
760 "bytes": b.bytes,
761 "node_count": b.nodes.len() as u64,
762 "edge_count": b_edges,
763 "string_count": b.string_count,
764 },
765 "current": {
766 "path": c.path,
767 "bytes": c.bytes,
768 "node_count": c.nodes.len() as u64,
769 "edge_count": c_edges,
770 "string_count": c.string_count,
771 },
772 "delta_bytes": (c.bytes as i64) - (b.bytes as i64),
773 "delta_nodes": (c.nodes.len() as i64) - (b.nodes.len() as i64),
774 "delta_edges": (c_edges as i64) - (b_edges as i64),
775 "delta_strings": (c.string_count as i64) - (b.string_count as i64),
776 "offline": true,
777 }))
778}
779
780pub fn duplicate_strings(path: &Path) -> Result<Value, String> {
781 let s = SnapshotGraph::load(path)?;
782 let mut freq: HashMap<&str, u64> = HashMap::new();
783 for st in &s.strings {
784 if st.is_empty() {
785 continue;
786 }
787 *freq.entry(st.as_str()).or_insert(0) += 1;
788 }
789 let pairs: Vec<(&str, u64)> = freq.into_iter().filter(|(_, c)| *c > 1).collect();
791 let mut dups: Vec<Value> = crate::concurrency::map_cpu(&pairs, |(s, c)| {
792 json!({
793 "string": if s.len() > 120 { format!("{}…", &s[..120]) } else { s.to_string() },
794 "count": c,
795 "bytes_est": (s.len() as u64) * c,
796 })
797 });
798 crate::concurrency::sort_by_cpu(&mut dups, |a, b| {
799 b.get("count")
800 .and_then(|v| v.as_u64())
801 .cmp(&a.get("count").and_then(|v| v.as_u64()))
802 });
803 let total = dups.len();
804 dups.truncate(50);
805 Ok(json!({
806 "path": s.path,
807 "duplicate_groups": total,
808 "top_duplicates": dups,
809 "offline": true,
810 }))
811}
812
813pub fn class_nodes(path: &Path, id: u64) -> Result<Value, String> {
815 let s = SnapshotGraph::load(path)?;
816 let mut top: Vec<(String, u64)> = s
817 .class_counts
818 .iter()
819 .map(|(k, v)| (k.clone(), *v))
820 .collect();
821 crate::concurrency::sort_by_key_cpu(&mut top, |b| std::cmp::Reverse(b.1));
822 let idx = id.saturating_sub(1) as usize;
823 let (name, count) = top.get(idx).cloned().ok_or_else(|| {
824 format!(
825 "class id {id} out of range (have {} classes; use 1-based rank)",
826 top.len()
827 )
828 })?;
829 let indices = s.class_to_nodes.get(&name).cloned().unwrap_or_default();
830 let truncated = indices.len() > DEFAULT_MAX_CLASS_NODES;
831 let node_ids: Vec<Value> = indices
832 .iter()
833 .take(DEFAULT_MAX_CLASS_NODES)
834 .map(|&i| s.node_json(i))
835 .collect();
836 Ok(json!({
837 "path": s.path,
838 "class_id": id,
839 "name": name,
840 "count": count,
841 "self_size": s.class_self_sizes.get(&name).copied().unwrap_or(0),
842 "nodes": node_ids,
843 "truncated": truncated,
844 "offline": true,
845 }))
846}
847
848pub fn object_details(path: &Path, node: u64) -> Result<Value, String> {
853 let s = SnapshotGraph::load(path)?;
854 let idx = s.resolve_node(node)?;
855 let object = s.object_info_json(idx);
856 Ok(json!({
857 "path": s.path,
858 "op": "object-details",
859 "object": object,
860 "offline": true,
861 }))
862}
863
864pub fn node_op(path: &Path, node: u64, op: &str) -> Result<Value, String> {
865 if op == "object-details" || op == "object_details" {
866 return object_details(path, node);
867 }
868 node_op_with_limits(
869 path,
870 node,
871 op,
872 DEFAULT_MAX_PATH_DEPTH,
873 DEFAULT_MAX_PATHS,
874 DEFAULT_MAX_RETAINERS,
875 DEFAULT_MAX_EDGES,
876 )
877}
878
879pub fn node_op_with_limits(
880 path: &Path,
881 node: u64,
882 op: &str,
883 max_depth: usize,
884 max_paths: usize,
885 max_retainers: usize,
886 max_edges: usize,
887) -> Result<Value, String> {
888 let s = SnapshotGraph::load(path)?;
889 let idx = s.resolve_node(node)?;
890 let node_info = s.node_json(idx);
891
892 match op {
893 "edges" => {
894 let edges = &s.out_edges[idx];
895 let truncated = edges.len() > max_edges;
896 let list: Vec<Value> = edges
897 .iter()
898 .take(max_edges)
899 .map(|e| s.edge_json(e))
900 .collect();
901 Ok(json!({
902 "path": s.path,
903 "op": "edges",
904 "node": node_info,
905 "edges": list,
906 "edge_count": edges.len(),
907 "truncated": truncated,
908 "offline": true,
909 }))
910 }
911 "retainers" => {
912 let edges = &s.in_edges[idx];
913 let truncated = edges.len() > max_retainers;
914 let list: Vec<Value> = edges
915 .iter()
916 .take(max_retainers)
917 .map(|e| s.edge_json(e))
918 .collect();
919 Ok(json!({
920 "path": s.path,
921 "op": "retainers",
922 "node": node_info,
923 "retainers": list,
924 "retainer_count": edges.len(),
925 "truncated": truncated,
926 "offline": true,
927 }))
928 }
929 "dominators" => {
930 let chain = s.dominator_chain(idx);
931 Ok(json!({
932 "path": s.path,
933 "op": "dominators",
934 "node": node_info,
935 "dominator_chain": chain,
936 "chain_length": chain.len(),
937 "offline": true,
938 }))
939 }
940 "paths" => {
941 let (paths, limits) = s.retaining_paths(idx, max_depth.max(1), max_paths.max(1));
942 Ok(json!({
943 "path": s.path,
944 "op": "paths",
945 "node": node_info,
946 "paths": paths,
947 "path_count": paths.len(),
948 "max_depth": max_depth,
949 "limits_reached": limits,
950 "offline": true,
951 }))
952 }
953 other => Ok(json!({
954 "path": s.path,
955 "op": other,
956 "node": node_info,
957 "offline": true,
958 })),
959 }
960}
961
962pub fn close_snapshot(path: &Path) -> Result<Value, String> {
964 let mut summary = summarize(path)?;
965 if let Some(obj) = summary.as_object_mut() {
966 obj.insert("closed".into(), json!(true));
967 obj.insert(
968 "note".into(),
969 json!("offline analysis complete; no in-process cache retained (one-shot)"),
970 );
971 }
972 Ok(summary)
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use std::io::Write;
979
980 fn write_fixture(path: &Path) {
985 let body = r#"{
990 "snapshot": {
991 "meta": {
992 "node_fields": ["type","name","id","self_size","edge_count"],
993 "node_types": [["hidden","object","string","synthetic"]],
994 "edge_fields": ["type","name_or_index","to_node"],
995 "edge_types": [["context","element","property","internal","hidden","shortcut","weak"]]
996 },
997 "node_count": 4,
998 "edge_count": 3
999 },
1000 "nodes": [
1001 3, 0, 10, 0, 2,
1002 1, 1, 11, 100, 1,
1003 1, 2, 12, 50, 0,
1004 1, 3, 13, 25, 0
1005 ],
1006 "edges": [
1007 2, 4, 5,
1008 2, 5, 15,
1009 2, 6, 10
1010 ],
1011 "strings": ["(GC roots)", "A", "B", "C", "toA", "toC", "toB"]
1012 }"#;
1013 let mut f = std::fs::File::create(path).unwrap();
1014 f.write_all(body.as_bytes()).unwrap();
1015 }
1016
1017 #[test]
1018 fn summarize_minimal_snapshot() {
1019 let dir = tempfile::tempdir().unwrap();
1020 let path = dir.path().join("t.heapsnapshot");
1021 write_fixture(&path);
1022 let s = summarize(&path).unwrap();
1023 assert_eq!(s["node_count"], 4);
1024 assert_eq!(s["offline"], true);
1025 }
1026
1027 #[test]
1028 fn edges_and_retainers_real_graph() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let path = dir.path().join("g.heapsnapshot");
1031 write_fixture(&path);
1032
1033 let edges_b = node_op(&path, 12, "edges").unwrap();
1035 assert_eq!(edges_b["edge_count"], 0);
1036
1037 let retainers_b = node_op(&path, 12, "retainers").unwrap();
1038 assert_eq!(retainers_b["retainer_count"], 1);
1039 let r0 = &retainers_b["retainers"][0];
1040 assert_eq!(r0["from_id"], 11); let edges_a = node_op(&path, 11, "edges").unwrap();
1043 assert_eq!(edges_a["edge_count"], 1);
1044 assert_eq!(edges_a["edges"][0]["to_id"], 12);
1045 }
1046
1047 #[test]
1048 fn dominators_chain_includes_root_and_node() {
1049 let dir = tempfile::tempdir().unwrap();
1050 let path = dir.path().join("d.heapsnapshot");
1051 write_fixture(&path);
1052 let d = node_op(&path, 12, "dominators").unwrap();
1053 let chain = d["dominator_chain"].as_array().unwrap();
1054 assert!(chain.len() >= 2);
1055 let last = chain.last().unwrap();
1056 assert_eq!(last["id"], 12);
1057 let first = &chain[0];
1058 assert_eq!(first["id"], 10);
1059 }
1060
1061 #[test]
1062 fn retaining_paths_finds_path() {
1063 let dir = tempfile::tempdir().unwrap();
1064 let path = dir.path().join("p.heapsnapshot");
1065 write_fixture(&path);
1066 let p = node_op(&path, 12, "paths").unwrap();
1067 let paths = p["paths"].as_array().unwrap();
1068 assert!(!paths.is_empty());
1069 assert!(paths[0]["nodes"].as_array().unwrap().len() >= 2);
1070 }
1071
1072 #[test]
1073 fn class_nodes_lists_ids() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let path = dir.path().join("c.heapsnapshot");
1076 write_fixture(&path);
1077 let cn = class_nodes(&path, 1).unwrap();
1079 assert!(!cn["nodes"].as_array().unwrap().is_empty());
1080 assert_eq!(cn["offline"], true);
1081 }
1082
1083 #[test]
1084 fn close_snapshot_flags_closed() {
1085 let dir = tempfile::tempdir().unwrap();
1086 let path = dir.path().join("x.heapsnapshot");
1087 write_fixture(&path);
1088 let c = close_snapshot(&path).unwrap();
1089 assert_eq!(c["closed"], true);
1090 }
1091
1092 #[test]
1093 fn dup_strings_counts() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let path = dir.path().join("dups.heapsnapshot");
1096 let body = r#"{
1097 "snapshot": { "meta": {
1098 "node_fields": ["type","name","id","self_size","edge_count"],
1099 "node_types": [["object"]],
1100 "edge_fields": ["type","name_or_index","to_node"],
1101 "edge_types": [["property"]]
1102 }, "node_count": 0, "edge_count": 0 },
1103 "nodes": [],
1104 "edges": [],
1105 "strings": ["a", "b", "a", "a", "c", "b"]
1106 }"#;
1107 std::fs::write(&path, body).unwrap();
1108 let d = duplicate_strings(&path).unwrap();
1109 assert_eq!(d["duplicate_groups"], 2);
1110 }
1111
1112 #[test]
1113 fn object_details_includes_distance_and_retained() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let path = dir.path().join("obj.heapsnapshot");
1116 write_fixture(&path);
1117 let o = object_details(&path, 12).unwrap();
1119 assert_eq!(o["op"], "object-details");
1120 assert_eq!(o["offline"], true);
1121 let obj = &o["object"];
1122 assert_eq!(obj["id"], 12);
1123 assert_eq!(obj["name"], "B");
1124 assert_eq!(obj["self_size"], 50);
1125 assert!(obj["retained_size"].as_u64().unwrap() >= 50);
1126 assert_eq!(obj["distance"], 2); assert_eq!(obj["retainer_count"], 1);
1128 assert_eq!(obj["detachedness"], "unknown");
1129
1130 let a = object_details(&path, 11).unwrap();
1132 let ao = &a["object"];
1133 assert_eq!(ao["distance"], 1);
1134 assert!(ao["retained_size"].as_u64().unwrap() >= 150);
1135 }
1136}