Skip to main content

browser_automation_cli/native/
heap_snapshot.rs

1//! Offline V8 `.heapsnapshot` analysis for `browser-automation-cli heap *`.
2#![allow(missing_docs)]
3//!
4//! Parses the Chrome heap snapshot JSON format and rebuilds a real object graph:
5//! outgoing edges, retainers (reverse edges), dominator chains, retaining paths,
6//! and per-node object details (distance, retained size, detachedness).
7
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::Path;
10
11use serde_json::{json, Value};
12
13/// Default caps to keep multi-GB snapshots agent-usable.
14const 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    /// V8 detachedness enum when present in `node_fields`; else `None`.
29    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    /// Outgoing edges by node index.
46    out_edges: Vec<Vec<EdgeRec>>,
47    /// Incoming edges by node index (retainers).
48    in_edges: Vec<Vec<EdgeRec>>,
49    /// node id field → node index
50    id_to_index: HashMap<u64, usize>,
51    class_counts: HashMap<String, u64>,
52    class_self_sizes: HashMap<String, u64>,
53    /// class name → node indices
54    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                        // element/property edges store string index; others may store numeric index
162                        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        // Prefer synthetic/(GC roots); else first node with no retainers; else 0.
238        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    /// BFS distance from the graph root along outgoing edges (`None` if unreachable).
252    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    /// Retained size per node: self_size of the node plus all nodes it dominates.
275    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        // Post-order DFS from each root of the dominator forest.
291        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                // Climb to dominator-tree root.
313                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    /// Full object details for one node (official object_details tool surface).
344    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    /// Immediate dominator tree via iterative data-flow (Cooper/Harvey/Kennedy style).
377    fn compute_idom(&self) -> Vec<Option<usize>> {
378        let n = self.nodes.len();
379        if n == 0 {
380            return Vec::new();
381        }
382
383        // Prefer synthetic/root-like nodes; else first node with no retainers; else 0.
384        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        // Build predecessor lists from reverse edges; ensure root has no preds.
410        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        // RPO via iterative DFS on forward graph.
423        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        // Orphans not reachable from root still get an entry.
443        for (i, was_visited) in visited.iter().enumerate() {
444            if !was_visited {
445                rpo.push(i);
446            }
447        }
448        rpo.reverse(); // reverse postorder
449
450        let mut idom: Vec<Option<usize>> = vec![None; n];
451        idom[root] = Some(root);
452
453        // Map index in rpo for semi-order compare.
454        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(); // root → … → node
516        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        // BFS upward on reverse edges toward roots (nodes with no retainers or synthetic).
526        let mut paths: Vec<Value> = Vec::new();
527        let mut limits = false;
528        // state: (node, path_of_node_indices from target upward)
529        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                // path was target→…→ancestor; reverse to root→…→target
551                if path.len() > max_depth && !is_root {
552                    // depth limit without root
553                    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
756/// `id` is 1-based rank into top classes by instance count.
757pub 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
791/// Detailed information about one heap object by node id (offline).
792///
793/// Returns id, name, type, self_size, retained_size, distance, edge_count,
794/// retainer_count, and detachedness — matching the official object-details surface.
795pub 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
905/// Close offline analysis handle (summary + explicit closed flag).
906pub 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    /// Tiny graph:
924    /// root(0) -prop-> A(1) -prop-> B(2)
925    /// root also retains C(3)
926    /// B retained only via A.
927    fn write_fixture(path: &Path) {
928        // node_fields: type, name, id, self_size, edge_count
929        // nodes: root, A, B, C
930        // edges for root: 2 (to A, to C); A: 1 (to B); B: 0; C: 0
931        // to_node is flat index = node_index * 5
932        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        // node id 12 = B
977        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); // A
984
985        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        // rank classes; A/B/C each count 1 — any rank 1+ works if class exists
1021        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        // B id=12: self 50, retained should include only self if nothing dominated
1061        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); // root -> A -> B
1070        assert_eq!(obj["retainer_count"], 1);
1071        assert_eq!(obj["detachedness"], "unknown");
1072
1073        // A id=11 retains B (50) + self 100
1074        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}