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