Skip to main content

browser_automation_cli/native/
heap_snapshot.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Offline V8 `.heapsnapshot` analysis for `browser-automation-cli heap *`.
3#![allow(missing_docs)]
4//!
5//! Parses the Chrome heap snapshot JSON format and rebuilds a real object graph:
6//! outgoing edges, retainers (reverse edges), dominator chains, retaining paths,
7//! and per-node object details (distance, retained size, detachedness).
8//!
9//! # Workload / parallelism (rules_rust_paralelismo)
10//!
11//! **CPU-bound** graph algorithms (dominators, retained size) over a single
12//! shared adjacency structure.
13//!
14//! - **PAR-93:** node materialize from flat arrays uses Rayon when
15//!   `n ≥ CPU_MAP_THRESHOLD`; class maps merge sequentially after.
16//! - **Dominator phases stay sequential** (N-142 / N-152): RPO → idom → retained
17//!   have data dependencies; blind `par_iter` races or multiplies RSS.
18//! - Independent post-idom maps (filter/score node lists, dups) use
19//!   [`crate::concurrency::map_cpu`] / [`crate::concurrency::sort_by_cpu`] when
20//!   length ≥ threshold (PAR-65 / PAR-107).
21//! - Entry point is **sync CLI** (`spawn_blocking` if ever called from async).
22//! - File size is hard-capped by [`MAX_HEAP_SNAPSHOT_BYTES`].
23
24use std::collections::{HashMap, HashSet, VecDeque};
25use std::path::Path;
26
27use serde_json::{json, Value};
28
29/// Default caps to keep multi-GB snapshots agent-usable.
30const 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;
35/// Hard file-size budget before reading a `.heapsnapshot` into RAM
36/// (rules: never `read_to_string` untrusted/huge input without a ceiling).
37const 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    /// V8 detachedness enum when present in `node_fields`; else `None`.
48    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    /// Outgoing edges by node index.
65    out_edges: Vec<Vec<EdgeRec>>,
66    /// Incoming edges by node index (retainers).
67    in_edges: Vec<Vec<EdgeRec>>,
68    /// node id field → node index
69    id_to_index: HashMap<u64, usize>,
70    class_counts: HashMap<String, u64>,
71    class_self_sizes: HashMap<String, u64>,
72    /// class name → node indices
73    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        // Capacity known from metadata → try_reserve before full read (OOM → Result).
93        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 the raw string early so peak RSS does not hold JSON text + Value.
104        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        // Pre-size when the node count is known; fail closed on OOM (untrusted snapshot).
134        let approx_nodes = nodes_flat.len() / node_stride.max(1);
135        // PAR-93: materialize NodeRec in parallel when large; merge class maps sequentially
136        // (HashMap shared mutation is not Rayon-safe). idom/RPO remain sequential (N-142).
137        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                        // element/property edges store string index; others may store numeric index
220                        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        // Prefer synthetic/(GC roots); else first node with no retainers; else 0.
296        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    /// BFS distance from the graph root along outgoing edges (`None` if unreachable).
310    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    /// Retained size per node: self_size of the node plus all nodes it dominates.
333    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        // Post-order DFS from each root of the dominator forest.
349        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                // Climb to dominator-tree root.
371                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    /// Full object details for one node (official object_details tool surface).
402    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    /// Immediate dominator tree via iterative data-flow (Cooper/Harvey/Kennedy style).
435    fn compute_idom(&self) -> Vec<Option<usize>> {
436        let n = self.nodes.len();
437        if n == 0 {
438            return Vec::new();
439        }
440
441        // Prefer synthetic/root-like nodes; else first node with no retainers; else 0.
442        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        // Build predecessor lists from reverse edges; ensure root has no preds.
468        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        // RPO via iterative DFS on forward graph.
481        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        // Orphans not reachable from root still get an entry.
501        for (i, was_visited) in visited.iter().enumerate() {
502            if !was_visited {
503                rpo.push(i);
504            }
505        }
506        rpo.reverse(); // reverse postorder
507
508        let mut idom: Vec<Option<usize>> = vec![None; n];
509        idom[root] = Some(root);
510
511        // Map index in rpo for semi-order compare.
512        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(); // root → … → node
574        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        // BFS upward on reverse edges toward roots (nodes with no retainers or synthetic).
584        let mut paths: Vec<Value> = Vec::new();
585        let mut limits = false;
586        // state: (node, path_of_node_indices from target upward)
587        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                // path was target→…→ancestor; reverse to root→…→target
609                if path.len() > max_depth && !is_root {
610                    // depth limit without root
611                    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    // PAR-107: large class lists sort on Rayon budget.
702    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    // PAR-65: independent string→json map after sequential freq count.
790    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
813/// `id` is 1-based rank into top classes by instance count.
814pub 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
848/// Detailed information about one heap object by node id (offline).
849///
850/// Returns id, name, type, self_size, retained_size, distance, edge_count,
851/// retainer_count, and detachedness — matching the official object-details surface.
852pub 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
962/// Close offline analysis handle (summary + explicit closed flag).
963pub 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    /// Tiny graph:
981    /// root(0) -prop-> A(1) -prop-> B(2)
982    /// root also retains C(3)
983    /// B retained only via A.
984    fn write_fixture(path: &Path) {
985        // node_fields: type, name, id, self_size, edge_count
986        // nodes: root, A, B, C
987        // edges for root: 2 (to A, to C); A: 1 (to B); B: 0; C: 0
988        // to_node is flat index = node_index * 5
989        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        // node id 12 = B
1034        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); // A
1041
1042        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        // rank classes; A/B/C each count 1 — any rank 1+ works if class exists
1078        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        // B id=12: self 50, retained should include only self if nothing dominated
1118        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); // root -> A -> B
1127        assert_eq!(obj["retainer_count"], 1);
1128        assert_eq!(obj["detachedness"], "unknown");
1129
1130        // A id=11 retains B (50) + self 100
1131        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}