mod bitset;
mod chunkvec;
mod collection_config;
mod cvec;
mod diff;
mod diff_reports;
mod dominator;
mod html;
mod id_map;
mod mat;
mod md;
#[cfg(test)]
mod md_test;
mod named_queries;
mod opts;
mod pass1;
mod pass2;
mod progress;
mod query;
mod reader;
mod report;
mod retained;
mod rpo_dfs;
mod run_oql;
mod serve;
mod source;
mod sweep;
mod trace;
mod types;
mod unreachable_retained;
mod update;
mod vbyte;
use std::io::IsTerminal;
use std::{io, process, time::Instant};
use opts::{AnalyzeOptions, DEFAULT_QUERY_PATH_DEPTH, DetailLevel, OutputFormat};
use pass1::Pass1;
use run_oql::{NoClassIndex, RetainedEdgeStructs, query_uses_edges, run_oql_escalated};
fn parse_query_path_depth(s: &str) -> Result<usize, String> {
let n: usize = s
.parse()
.map_err(|_| format!("`{s}` is not a valid non-negative integer for --query-path-depth"))?;
if n == 0 {
return Err(
"--query-path-depth must be > 0 (bounded path walks need at least one hop)".into(),
);
}
Ok(n)
}
use clap::{CommandFactory, Parser, Subcommand, ValueEnum, ValueHint};
use clap_complete::Shell;
#[derive(Parser)]
#[command(
name = "hprof-analyzer",
version,
about = "Analyze Java HPROF heap dumps (Eclipse MAT parity)",
long_about = "A fast, low-memory analyzer for Java HPROF heap dumps.\n\n\
Give it a heap dump and it parses the dump in a few streaming passes and \
emits static reports that replicate three Eclipse MAT views: System \
Overview, Leak Suspects, and Top Consumers, plus a Threads overview and \
some extended collection views. Give it a saved Report JSON instead and \
it re-renders that report without re-parsing the dump. Reports render as \
plain Markdown, Markdown with ASCII graphs, self-contained HTML, or \
machine-readable JSON.",
after_help = "EXAMPLES:\n \
hprof-analyzer heap.hprof # Markdown to stdout\n \
hprof-analyzer heap.hprof report.html # HTML (format from .html)\n \
hprof-analyzer heap.hprof report.json # JSON (format from .json)\n \
hprof-analyzer heap.hprof -f md-graphs # Markdown + ASCII graphs\n \
hprof-analyzer report.json report.html # re-render saved JSON to HTML\n \
hprof-analyzer query heap.hprof --query 'SELECT COUNT(*) FROM java.lang.String' # ad-hoc OQL\n \
hprof-analyzer query heap.hprof --repl # interactive OQL shell\n \
hprof-analyzer heap.hprof out.html --query-file q.oql # queries folded into a report\n \
hprof-analyzer compare reports r1.json r2.json [r3.json …] # cross-dump growth diff\n \
hprof-analyzer completions zsh > _hprof-analyzer # shell completions\n\n\
OQL grammar, the -- @viz chart directive, and the --query= equals-form\n \
gotcha are documented in docs/OQL.md.\n\n\
Install zsh completions:\n \
hprof-analyzer completions zsh > \"${fpath[1]}/_hprof-analyzer\"",
args_conflicts_with_subcommands = true
)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
#[arg(value_hint = ValueHint::FilePath)]
input: Option<String>,
#[arg(value_hint = ValueHint::AnyPath)]
output: Option<String>,
#[arg(short, long, value_enum)]
format: Option<FormatArg>,
#[arg(long, value_enum, default_value_t = DetailLevel::Default)]
detail: DetailLevel,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
trace_rss: bool,
#[arg(long, value_enum, default_value_t = ProgressWhen::Auto)]
progress: ProgressWhen,
#[arg(long)]
find_duplicates: bool,
#[arg(long)]
collections: bool,
#[arg(long, value_name = "PATH")]
collection_config: Option<std::path::PathBuf>,
#[arg(long = "query", value_name = "OQL")]
query: Vec<String>,
#[arg(long = "query-file", value_name = "PATH", value_hint = ValueHint::FilePath)]
query_file: Option<String>,
#[arg(long = "query-path-depth", value_name = "N", default_value_t = DEFAULT_QUERY_PATH_DEPTH, value_parser = parse_query_path_depth)]
query_path_depth: usize,
#[arg(long)]
reachable_only: bool,
#[arg(long)]
ref_paths: bool,
#[arg(long)]
field_stats: bool,
#[arg(long, value_name = "N")]
hist_root_path_top: Option<usize>,
#[arg(long)]
full_analysis: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "small", value_name = "TIER")]
obj_graph: Option<String>,
#[arg(long, default_value = "default", value_name = "SIZE")]
size: String,
#[arg(long)]
dev: bool,
#[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
bundle_path: Option<std::path::PathBuf>,
#[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
mat: Option<std::path::PathBuf>,
#[arg(long, value_hint = ValueHint::FilePath)]
mat_binary: Option<std::path::PathBuf>,
}
#[derive(Subcommand)]
enum Cmd {
Update {
#[arg(value_enum)]
channel: Option<update::Channel>,
},
Compare {
#[command(subcommand)]
cmd: CompareCmd,
},
Completions {
shell: Shell,
},
Dev {
#[command(subcommand)]
cmd: DevCmd,
},
Server {
#[arg(value_hint = ValueHint::FilePath)]
input: String,
#[arg(long, value_name = "N")]
port: Option<u16>,
},
Query {
#[arg(value_hint = ValueHint::FilePath)]
input: String,
#[arg(long = "query", value_name = "OQL")]
query: Vec<String>,
#[arg(long = "query-file", value_name = "PATH", value_hint = ValueHint::FilePath)]
query_file: Option<String>,
#[arg(long = "query-path-depth", value_name = "N", default_value_t = DEFAULT_QUERY_PATH_DEPTH, value_parser = parse_query_path_depth)]
query_path_depth: usize,
#[arg(long)]
repl: bool,
#[arg(long, conflicts_with = "repl")]
server: bool,
#[arg(long, value_name = "N")]
port: Option<u16>,
#[arg(long, conflicts_with = "all")]
reachable_only: bool,
#[arg(long)]
all: bool,
#[arg(long = "run", value_name = "NAME")]
run: Option<String>,
#[arg(long = "list-named")]
list_named: bool,
#[arg(short, long, value_enum, default_value_t = QueryFormatArg::Text)]
format: QueryFormatArg,
#[arg(long, value_enum, default_value_t = ProgressWhen::Auto)]
progress: ProgressWhen,
},
Mat {
#[command(subcommand)]
cmd: MatCmd,
},
}
#[derive(Subcommand)]
enum MatCmd {
Caches {
#[arg(value_hint = ValueHint::FilePath)]
input: String,
#[arg(value_hint = ValueHint::DirPath)]
dir: Option<String>,
#[arg(long, value_hint = ValueHint::FilePath)]
mat_binary: Option<String>,
},
}
#[derive(Subcommand)]
enum CompareCmd {
Mat {
#[arg(value_hint = ValueHint::FilePath)]
mat: String,
#[arg(value_hint = ValueHint::FilePath)]
ours: String,
#[arg(short, long, value_enum)]
format: Option<FormatArg>,
},
Reports {
#[arg(value_hint = ValueHint::FilePath, num_args = 2..)]
reports: Vec<String>,
#[arg(short, long, value_enum)]
format: Option<FormatArg>,
#[arg(short, long, value_hint = ValueHint::FilePath)]
output: Option<String>,
},
}
#[derive(Subcommand)]
enum DevCmd {
EmitSchema,
SweepAggregate {
#[arg(value_hint = ValueHint::DirPath)]
dir: String,
},
DumpPass1 {
#[arg(value_hint = ValueHint::FilePath)]
input: String,
},
}
#[derive(Clone, Copy, PartialEq, ValueEnum)]
enum FormatArg {
Md,
MdGraphs,
Json,
Html,
}
#[derive(Clone, Copy, PartialEq, ValueEnum)]
enum QueryFormatArg {
Text,
Json,
}
impl ValueEnum for DetailLevel {
fn value_variants<'a>() -> &'a [Self] {
&[DetailLevel::Minimal, DetailLevel::Default, DetailLevel::Max]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
Some(match self {
DetailLevel::Minimal => clap::builder::PossibleValue::new("minimal"),
DetailLevel::Default => clap::builder::PossibleValue::new("default"),
DetailLevel::Max => clap::builder::PossibleValue::new("max"),
})
}
}
#[derive(Clone, Copy, PartialEq, ValueEnum)]
enum ProgressWhen {
Auto,
Always,
Never,
}
impl From<FormatArg> for OutputFormat {
fn from(f: FormatArg) -> Self {
match f {
FormatArg::Md => OutputFormat::Md,
FormatArg::MdGraphs => OutputFormat::MdGraphs,
FormatArg::Json => OutputFormat::Json,
FormatArg::Html => OutputFormat::Html,
}
}
}
fn resolve_format(explicit: Option<FormatArg>, out: Option<&str>) -> OutputFormat {
if let Some(f) = explicit {
return f.into();
}
if let Some(path) = out {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".html") || lower.ends_with(".htm") {
return OutputFormat::Html;
}
if lower.ends_with(".json") || lower.ends_with(".json.gz") {
return OutputFormat::Json;
}
}
OutputFormat::Md
}
fn write_output(path: Option<&str>, text: &str) -> io::Result<()> {
match path {
Some(p) if p.ends_with(".gz") => {
use std::io::Write;
let f = std::fs::File::create(p).map_err(|e| io::Error::new(e.kind(), e))?;
let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::best());
enc.write_all(text.as_bytes())?;
enc.finish()?;
Ok(())
}
Some("-") | None => {
print!("{text}");
Ok(())
}
Some(p) => std::fs::write(p, text).map_err(|e| io::Error::new(e.kind(), e)),
}
}
fn fail(msg: impl std::fmt::Display) -> ! {
eprintln!("error: {msg}");
process::exit(1);
}
fn main() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
match cli.cmd {
None => run_default(cli),
Some(Cmd::Update { channel }) => {
if let Err(e) = update::run(channel) {
fail(e);
}
}
Some(Cmd::Compare { cmd }) => match cmd {
CompareCmd::Mat { mat, ours, format } => {
for p in [&mat, &ours] {
if p != "-" && !std::path::Path::new(p).exists() {
fail(format!("cannot open '{p}': no such file or directory"));
}
}
let json_out = resolve_format(format, None) == OutputFormat::Json;
match diff::run_diff(&mat, &ours, json_out) {
Ok(true) => {}
Ok(false) => process::exit(2),
Err(e) => fail(e),
}
}
CompareCmd::Reports {
reports,
format,
output,
} => {
for p in &reports {
if p != "-" && !std::path::Path::new(p).exists() {
fail(format!("cannot open '{p}': no such file or directory"));
}
}
match diff_reports::run(&reports, resolve_format(format, None)) {
Ok(text) => {
if let Some(path) = output {
let bytes = text.into_bytes();
if path.ends_with(".gz") {
use std::io::Write;
match std::fs::File::create(&path) {
Ok(f) => {
let mut gz = flate2::write::GzEncoder::new(
f,
flate2::Compression::default(),
);
if let Err(e) = gz
.write_all(&bytes)
.and_then(|_| gz.finish().map(|_| ()))
{
fail(format!("gzip write error: {e}"));
}
}
Err(e) => fail(format!("cannot create '{path}': {e}")),
}
} else if let Err(e) = std::fs::write(&path, &bytes) {
fail(format!("cannot write '{path}': {e}"));
}
} else {
print!("{text}");
}
}
Err(e) => fail(e),
}
}
},
Some(Cmd::Completions { shell }) => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "hprof-analyzer", &mut io::stdout());
}
Some(Cmd::Dev { cmd }) => match cmd {
DevCmd::EmitSchema => {
let schema = schemars::schema_for!(report::Report);
match serde_json::to_string_pretty(&schema) {
Ok(js) => println!("{js}"),
Err(e) => fail(e),
}
}
DevCmd::SweepAggregate { dir } => match sweep::run_aggregate(&dir) {
Ok(true) => {}
Ok(false) => process::exit(2),
Err(e) => fail(e),
},
DevCmd::DumpPass1 { input } => {
if let Err(e) = dump_pass1_json(&input) {
fail(e);
}
}
},
Some(Cmd::Server { input, port }) => {
if !input_is_hprof(&input) {
fail(format!(
"'{input}' is not an HPROF dump; the `server` subcommand needs a .hprof[.gz/.zip/.tar.gz] file"
));
}
let opts = AnalyzeOptions {
reachable_only: true,
..DetailLevel::Default.options()
};
if let Err(e) = serve::run_server(&input, port.unwrap_or(serve::DEFAULT_PORT), opts) {
fail(analyze_error_hint(&input, &e));
}
}
Some(Cmd::Query {
input,
query,
query_file,
query_path_depth,
repl,
server,
port,
reachable_only: _,
all,
run,
list_named,
format,
progress,
}) => {
if !input_is_hprof(&input) {
fail(format!(
"'{input}' is not an HPROF dump; the `query` subcommand needs a .hprof[.gz/.zip/.tar.gz] file"
));
}
if (server || repl) && (!query.is_empty() || query_file.is_some()) {
let mode = if server { "--server" } else { "--repl" };
eprintln!(
"warning: {mode} takes queries interactively; --query/--query-file are ignored"
);
}
if server {
if let Err(e) = crate::query::server::run_server(
&input,
query_path_depth,
port.unwrap_or(serve::DEFAULT_PORT),
) {
fail(analyze_error_hint(&input, &e));
}
} else if repl {
if let Err(e) = crate::query::repl::run_repl(&input, query_path_depth) {
fail(analyze_error_hint(&input, &e));
}
} else {
if list_named {
for nq in crate::named_queries::NAMED_QUERIES {
println!("{:40} [{}] {}", nq.name, nq.group, nq.display);
}
return;
}
let mut queries_vec = query;
if let Some(ref name) = run {
let nq = crate::named_queries::NAMED_QUERIES
.iter()
.find(|q| q.name == name);
match nq {
None => {
let prefix_end = name
.char_indices()
.nth(3)
.map(|(i, _)| i)
.unwrap_or(name.len());
let prefix = &name[..prefix_end];
let candidates: Vec<&str> = crate::named_queries::NAMED_QUERIES
.iter()
.filter(|q| q.name.starts_with(prefix))
.map(|q| q.name)
.collect();
eprintln!("error: unknown named query {:?}", name);
if !candidates.is_empty() {
eprintln!(" did you mean: {}", candidates.join(", "));
}
std::process::exit(1);
}
Some(nq) => {
queries_vec.push(nq.oql.to_string());
}
}
}
let opts = AnalyzeOptions {
queries: queries_vec,
query_file,
query_path_depth,
reachable_only: !all,
..DetailLevel::Default.options()
};
let show_progress = match progress {
ProgressWhen::Always => true,
ProgressWhen::Never => false,
ProgressWhen::Auto => std::io::stderr().is_terminal(),
};
progress::set_enabled(show_progress);
let json_out = format == QueryFormatArg::Json;
if let Err(e) = run_queries(&input, opts, json_out) {
fail(analyze_error_hint(&input, &e));
}
}
}
Some(Cmd::Mat { cmd }) => match cmd {
MatCmd::Caches {
input,
dir,
mat_binary,
} => {
if !input_is_hprof(&input) {
fail(format!(
"'{input}' does not look like a .hprof[.gz/.zip/.tar.gz] file"
));
}
progress::set_enabled(std::io::stderr().is_terminal());
let mat_dir = dir.as_deref().unwrap_or_else(|| {
std::path::Path::new(&input)
.parent()
.and_then(|p| p.to_str())
.unwrap_or(".")
});
let base = std::path::Path::new(&input)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("dump");
let prefix = base
.strip_suffix(".hprof.tar.gz")
.or_else(|| base.strip_suffix(".hprof.gz"))
.or_else(|| base.strip_suffix(".tar.gz"))
.or_else(|| base.strip_suffix(".tgz"))
.or_else(|| base.strip_suffix(".hprof"))
.unwrap_or(base);
let mat_bin_path = mat_binary.as_deref().map(std::path::Path::new);
let mat_emitter =
match mat::MatEmitter::new(std::path::Path::new(mat_dir), prefix, mat_bin_path)
{
Ok(e) => e,
Err(e) => fail(format!("cannot create MAT index dir '{mat_dir}': {e}")),
};
if let Err(e) = run(
&input,
Some("/dev/null"),
OutputFormat::Md,
false,
cvec::Codec::Deflate9,
AnalyzeOptions {
skip_report: true,
..DetailLevel::Default.options()
},
Some(mat_emitter),
) {
fail(e);
}
}
},
}
}
fn run_default(cli: Cli) {
let Some(input) = cli.input else {
let mut cmd = Cli::command();
let _ = cmd.write_help(&mut io::stderr());
eprintln!();
process::exit(2);
};
if input_is_hprof(&input) {
let show_progress = match cli.progress {
ProgressWhen::Always => true,
ProgressWhen::Never => false,
ProgressWhen::Auto => !cli.verbose && std::io::stderr().is_terminal(),
};
progress::set_enabled(show_progress);
trace::set_enabled(cli.trace_rss);
let fmt = if cli.dev || cli.bundle_path.is_some() {
let base = resolve_format(cli.format, cli.output.as_deref());
if base != OutputFormat::Html {
OutputFormat::Html
} else {
base
}
} else {
resolve_format(cli.format, cli.output.as_deref())
};
let opts = cli.detail.options();
let mut opts = AnalyzeOptions {
find_duplicates: cli.find_duplicates || cli.full_analysis,
collections: cli.collections || cli.full_analysis,
collection_config: cli.collection_config.clone(),
coll_descs: crate::collection_config::load_collection_descs(
cli.collection_config.as_deref(),
),
queries: cli.query.clone(),
query_file: cli.query_file.clone(),
query_path_depth: cli.query_path_depth,
reachable_only: cli.reachable_only,
ref_paths: cli.ref_paths,
field_stats: cli.field_stats,
obj_graph: cli.obj_graph.is_some() || cli.full_analysis,
dev_report: cli.dev || cli.bundle_path.is_some(),
bundle_path: cli.bundle_path.clone(),
..opts
};
if let Some(n) = cli.hist_root_path_top {
opts.hist_root_path_top = n;
}
opts.report_size = match cli
.obj_graph
.as_deref()
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("medium") => crate::opts::ReportSize::Default,
Some("large") => crate::opts::ReportSize::Large,
None | Some("small") => crate::opts::ReportSize::Small,
Some(other) => {
eprintln!(
"error: unknown --obj-graph tier '{other}' (expected: small, medium, large)"
);
std::process::exit(2);
}
};
opts.report_size = match cli.size.to_ascii_lowercase().as_str() {
"small" => crate::opts::ReportSize::Small,
"default" => crate::opts::ReportSize::Default,
"large" => crate::opts::ReportSize::Large,
"max" => crate::opts::ReportSize::Max,
other => {
eprintln!(
"error: unknown --size tier '{other}' (expected: small, default, large, max)"
);
std::process::exit(2);
}
};
let mat = match cli.mat.as_deref() {
Some(dir) => {
let base = std::path::Path::new(&input)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("dump");
let prefix = base
.strip_suffix(".hprof.tar.gz")
.or_else(|| base.strip_suffix(".hprof.gz"))
.or_else(|| base.strip_suffix(".tar.gz"))
.or_else(|| base.strip_suffix(".tgz"))
.or_else(|| base.strip_suffix(".hprof"))
.unwrap_or(base);
match mat::MatEmitter::new(dir, prefix, cli.mat_binary.as_deref()) {
Ok(e) => Some(e),
Err(e) => fail(format!(
"cannot create MAT index dir '{}': {e}",
dir.display()
)),
}
}
None => None,
};
if let Err(e) = run(
&input,
cli.output.as_deref(),
fmt,
cli.verbose,
cvec::Codec::Deflate9,
opts,
mat,
) {
fail(analyze_error_hint(&input, &e));
}
} else {
if cli.collections {
fail(
"--collections has no effect when re-rendering a saved report; \
re-run on the .hprof dump to include it",
);
}
if cli.collection_config.is_some() {
fail(
"--collection-config has no effect when re-rendering a saved report; \
re-run on the .hprof dump to use it",
);
}
if cli.find_duplicates {
fail(
"--find-duplicates has no effect when re-rendering a saved report; \
re-run on the .hprof dump to include it",
);
}
if cli.mat.is_some() {
fail(
"--mat has no effect when re-rendering a saved report; \
re-run on the .hprof dump to emit MAT index files",
);
}
if cli.detail != DetailLevel::Default {
fail(
"--detail has no effect when re-rendering a saved report; \
re-run on the .hprof dump to change output caps",
);
}
let fmt = resolve_format(cli.format, cli.output.as_deref());
match render_report(&input, fmt) {
Ok(text) => {
if let Err(e) = write_output(cli.output.as_deref(), &text) {
let target = cli.output.as_deref().unwrap_or("<stdout>");
fail(format!("cannot write '{target}': {e}"));
}
}
Err(e) => fail(render_error_hint(&input, &e)),
}
}
}
pub(crate) fn analyze_to_report_with_retained(
source: &crate::source::HprofSource,
opts: &AnalyzeOptions,
) -> std::io::Result<(crate::report::Report, Vec<u64>)> {
analyze_to_report_inner(source, opts)
}
fn analyze_to_report_inner(
source: &crate::source::HprofSource,
opts: &AnalyzeOptions,
) -> std::io::Result<(crate::report::Report, Vec<u64>)> {
let p1 = pass1::Pass1::run(source, false)?;
let truncated_input = p1.truncated_input;
if p1.class_ids.len() > u32::MAX as usize {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"dump has {} objects, exceeding the {} (u32::MAX) limit of the \
analyzer's index scheme; cannot analyze",
p1.class_ids.len(),
u32::MAX
),
));
}
let compress = cvec::Codec::Deflate9;
let mut no_in_sets = std::collections::HashMap::new();
let mut no_exists_bools = std::collections::HashMap::new();
let (
mut g,
mut inbound,
shallow_c,
class_idx_c,
alloc_serial_c,
_query_state,
_refwalk_csr,
_string_values,
_string_values_truncated,
) = pass2::Pass2::build(
source,
p1,
compress,
opts,
&[],
&mut no_in_sets,
&mut no_exists_bools,
)?;
inbound.compress_id_map(compress)?;
let rpo = rpo_dfs::rpo_dfs(g.n, &g.gc_root_indices, &g.fwd_offsets, &g.fwd_targets);
{
g.unreachable_retained = unreachable_retained::compute_unreachable_retained(
g.n,
&rpo.dfn,
&g.fwd_offsets,
&g.fwd_targets,
&shallow_c,
&class_idx_c,
g.class_names.len(),
&g.class_obj_class_idx,
&g.class_names,
)?;
}
let mut rpo = rpo;
let parent_pre_count = rpo.parent_pre.len();
let parent_pre_c = if compress != cvec::Codec::None {
let c = cvec::CompressedU32::compress(&rpo.parent_pre, compress)?;
crate::trace::drop_vec(std::mem::take(&mut rpo.parent_pre));
Some(c)
} else {
None
};
if opts.obj_graph {
g.class_idx = class_idx_c.restore()?;
let (pairs, pair_fields) = crate::pass2::capture_type_ref_graph(&g);
crate::trace::drop_vec(std::mem::take(&mut g.class_idx));
g.type_ref_pairs = Some(pairs);
g.type_ref_pair_fields = Some(pair_fields);
crate::trace::probe("main: after capture_type_ref_graph");
g.obj_graph_edges = Some(crate::pass2::capture_obj_graph_edges(
&g,
500_000,
opts.report_size.edge_cap(),
));
crate::trace::probe("main: after capture_obj_graph_edges");
}
let field_stats_fwd: Option<(Vec<u32>, crate::chunkvec::ChunkU32)> = if opts.field_stats {
let total_edges = g.fwd_offsets.last().copied().unwrap_or(0) as usize;
let fwd_off_copy = g.fwd_offsets.clone();
let mut fwd_tgt_copy = crate::chunkvec::ChunkU32::zeroed(total_edges);
for i in 0..total_edges {
fwd_tgt_copy.set(i, g.fwd_targets.get(i));
}
Some((fwd_off_copy, fwd_tgt_copy))
} else {
None
};
let (inb_block_off, inb_data) = if inbound.total_inb > 1_000_000_000 {
drop(std::mem::take(&mut g.fwd_targets));
drop(std::mem::take(&mut g.fwd_offsets));
inbound.build_mat_scan(&rpo.dfn, |_src, _fwd| Ok(()))?
} else {
inbound.build_from_fwd(
std::mem::take(&mut g.fwd_offsets),
std::mem::take(&mut g.fwd_targets),
&rpo.dfn,
)?
};
let count = parent_pre_count;
rpo.vertex = rpo_dfs::rebuild_vertex(&rpo.dfn, count);
rpo.dfn = Vec::new();
if let Some(c) = parent_pre_c {
rpo.parent_pre = c.restore()?;
}
g.idom =
dominator::compute_dominators(g.n, rpo, &g.gc_root_indices, &inb_block_off, &inb_data)?;
drop(inb_block_off);
drop(inb_data);
let (dc_off, dc_tgt) = retained::build_dom_children_csr(g.n, &g.idom);
if compress != cvec::Codec::None {
g.shallow = shallow_c.restore()?;
g.class_idx = class_idx_c.restore()?;
}
drop(shallow_c);
drop(class_idx_c);
let class_count = g.class_names.len();
let (retained, has_same, depth_counts) = retained::compute_retained(
g.n,
&g.shallow,
&g.class_idx,
class_count,
&g.class_obj_class_idx,
&dc_off,
&dc_tgt,
);
g.retained = retained;
g.has_same_class_ancestor = has_same;
let precomputed_field_stats: Option<crate::report::FieldStats> =
if let Some((fwd_off, fwd_tgt)) = field_stats_fwd {
g.fwd_offsets = fwd_off;
g.fwd_targets = fwd_tgt;
let fs = crate::report::build_field_stats(&g);
g.fwd_offsets = Vec::new();
g.fwd_targets = crate::chunkvec::ChunkU32::default();
Some(fs)
} else {
None
};
let alloc_sites = if let Some(c) = alloc_serial_c {
let mut agg = report::AllocAgg::new(&g, opts.alloc_sites_top);
c.for_each_u32(|serial| agg.push(serial))?;
let a = agg.finish();
g.alloc_frames_by_serial = None;
Some(a)
} else {
let a = report::build_alloc_sites(&g, opts.alloc_sites_top);
g.alloc_stack_serial = Vec::new();
g.alloc_frames_by_serial = None;
Some(a)
};
let mut report = report::build_model(
&mut g,
dc_off,
dc_tgt,
opts.leak_children_cap,
&depth_counts,
opts,
alloc_sites,
precomputed_field_stats,
);
report.truncated_input = truncated_input;
let retained = std::mem::take(&mut g.retained);
Ok((report, retained))
}
fn input_is_hprof(input: &str) -> bool {
if input == "-" {
return false;
}
let lower = input.to_ascii_lowercase();
if lower.ends_with(".hprof")
|| lower.ends_with(".hprof.gz")
|| lower.ends_with(".hprof.zip")
|| lower.ends_with(".tar.gz")
|| lower.ends_with(".tgz")
{
return true;
}
looks_like_hprof(input)
}
fn analyze_error_hint(input: &str, e: &io::Error) -> String {
let msg = e.to_string();
if e.kind() == io::ErrorKind::NotFound && !msg.starts_with("cannot ") {
return format!("cannot open '{input}': no such file or directory");
}
if e.kind() == io::ErrorKind::UnexpectedEof && looks_like_hprof(input) {
return format!(
"'{input}' appears truncated or corrupt — hit end of file mid-record; \
re-copy the .hprof dump and retry"
);
}
if input != "-" && std::fs::metadata(input).is_ok() && !looks_like_hprof(input) {
return format!(
"'{input}' does not start with the HPROF magic; \
if it is a saved report JSON, rename it without the .hprof \
extension to re-render it"
);
}
msg
}
fn render_error_hint(input: &str, e: &io::Error) -> String {
if e.kind() == io::ErrorKind::NotFound {
return format!("cannot open '{input}': no such file or directory");
}
let msg = e.to_string();
if msg.starts_with("invalid report JSON") && input != "-" {
match sniff_report_kind(input) {
Some("html") => {
return format!(
"{msg}\n(hint: '{input}' looks like a rendered HTML report; \
re-render from the saved report JSON (.json/.json.gz), not \
the .html)"
);
}
Some("markdown") => {
return format!(
"{msg}\n(hint: '{input}' looks like a rendered Markdown report; \
re-render from the saved report JSON (.json/.json.gz))"
);
}
_ => {
return format!(
"{msg}\n(hint: expected a saved report JSON (.json/.json.gz); \
analyze a .hprof dump to produce one)"
);
}
}
}
msg
}
fn sniff_report_kind(path: &str) -> Option<&'static str> {
use std::io::Read;
let mut f = std::fs::File::open(path).ok()?;
let mut head = [0u8; 512];
let n = f.read(&mut head).ok()?;
let head = &head[..n];
let decoded;
let bytes: &[u8] = if head.starts_with(&[0x1f, 0x8b]) {
let mut d = flate2::read::GzDecoder::new(head);
let mut buf = Vec::new();
let _ = d.read_to_end(&mut buf);
if buf.is_empty() {
return None;
}
decoded = buf;
&decoded
} else {
head
};
let s = String::from_utf8_lossy(bytes);
let t = s.trim_start();
let lower = t.to_ascii_lowercase();
if t.starts_with('<') || lower.contains("<!doctype") || lower.contains("<html") {
return Some("html");
}
if t.starts_with('#') || t.starts_with('|') {
return Some("markdown");
}
None
}
fn looks_like_hprof(path: &str) -> bool {
use std::io::Read;
if path == "-" {
return false;
}
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut head = [0u8; 12];
matches!(f.read_exact(&mut head), Ok(())) && head.starts_with(b"JAVA PROFILE")
}
fn could_be_truncated_hprof(path: &str) -> bool {
use std::io::Read;
if path == "-" {
return false;
}
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut head = [0u8; 4];
let n = f.read(&mut head).unwrap_or(0);
if n == 0 {
return true;
}
let h = &head[..n];
if matches!(h[0], b'{' | b'[' | b'<' | b'"') {
return false;
}
true
}
fn rss_mb() -> f64 {
#[cfg(target_os = "linux")]
{
if let Ok(s) = std::fs::read_to_string("/proc/self/status") {
for line in s.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
let kb: u64 = rest
.split_whitespace()
.next()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
return kb as f64 / 1024.0;
}
}
}
}
0.0
}
fn log(verbose: bool, phase: &str, elapsed: f64) {
if verbose {
let rss = rss_mb();
if rss > 0.0 {
eprintln!("{phase}: {elapsed:.2}s RSS={rss:.0} MB");
} else {
eprintln!("{phase}: {elapsed:.2}s");
}
}
}
fn render_report(path: &str, format: OutputFormat) -> io::Result<String> {
use std::io::Read;
let raw = if path == "-" {
let mut buf = Vec::new();
io::stdin().read_to_end(&mut buf)?;
buf
} else {
std::fs::read(path)?
};
let json = if raw.starts_with(&[0x1f, 0x8b]) {
let mut d = flate2::read::GzDecoder::new(&raw[..]);
let mut s = String::new();
d.read_to_string(&mut s)?;
s
} else {
String::from_utf8(raw).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("input not UTF-8: {e}"))
})?
};
let report: report::Report = serde_json::from_str(&json).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid report JSON: {e}"),
)
})?;
if report.schema_version > report::SCHEMA_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"report schema_version {} is newer than supported version {}; refusing to render",
report.schema_version,
report::SCHEMA_VERSION
),
));
}
Ok(match format {
OutputFormat::Md => report::render_markdown(&report),
OutputFormat::MdGraphs => report::render_markdown_graphs(&report),
OutputFormat::Json => serde_json::to_string_pretty(&report).map_err(io::Error::other)?,
OutputFormat::Html => html::render_html(&report),
})
}
struct CollectedQuery {
text: String,
viz: Option<query::viz::VizSpec>,
warning: Option<String>,
name: Option<String>,
}
fn collect_query_texts(opts: &AnalyzeOptions) -> io::Result<Vec<CollectedQuery>> {
let mut collected: Vec<CollectedQuery> = opts
.queries
.iter()
.map(|q| {
let (text, viz, warning) = query::viz::split_directive(q);
CollectedQuery {
text,
viz,
warning,
name: None,
}
})
.collect();
if let Some(ref qf) = opts.query_file {
let body = std::fs::read_to_string(qf).map_err(|e| {
io::Error::new(e.kind(), format!("cannot read --query-file '{qf}': {e}"))
})?;
let mut pending_directive: Option<String> = None;
let mut line_num = 0usize;
for line in body.lines() {
line_num += 1;
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
if is_viz_directive_line(t) {
pending_directive = Some(t.to_string());
continue;
}
let full = match pending_directive.take() {
Some(dir) => format!("{dir}\n{t}"),
None => t.to_string(),
};
let (text, viz, warning) = query::viz::split_directive(&full);
if let Err(e) = query::parse::parse(&text) {
let semi_hint = if e.0.contains(';') || text.contains(';') {
" (each line is one query; semicolons are not supported — put each query on its own line)"
} else {
""
};
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"--query-file '{qf}': parse error on line {line_num}: {}{semi_hint}",
e.0
),
));
}
collected.push(CollectedQuery {
text,
viz,
warning,
name: None,
});
}
}
for cq in crate::collection_config::load_config_queries(opts.collection_config.as_deref()) {
let (text, viz, warning) = query::viz::split_directive(&cq.oql);
collected.push(CollectedQuery {
text,
viz,
warning,
name: cq.name,
});
}
Ok(collected)
}
fn is_viz_directive_line(t: &str) -> bool {
t.strip_prefix("--")
.map(str::trim_start)
.and_then(|r| r.split_whitespace().next())
.is_some_and(|w| w.eq_ignore_ascii_case("@viz"))
}
fn parse_plan_queries(
query_texts: &[String],
depth_cap: usize,
) -> io::Result<Vec<(query::ast::Query, query::plan::QueryPlan)>> {
let mut parsed_queries: Vec<(query::ast::Query, query::plan::QueryPlan)> =
Vec::with_capacity(query_texts.len());
for text in query_texts {
let q = query::parse::parse_or_report(text).map_err(|report| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("OQL parse error in `{text}`:\n{report}"),
)
})?;
let plan = query::plan::plan_query(&q, depth_cap).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("OQL plan error in `{text}`: {}", e.0),
)
})?;
let plan = query::optimize::optimize(plan, &q, &query::optimize::SchemaStats::default());
parsed_queries.push((q, plan));
}
Ok(parsed_queries)
}
fn finalize_query_labels(
results: &mut [query::model::QueryResult],
query_texts: &[String],
queries: &[(query::ast::Query, query::plan::QueryPlan)],
) {
use std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
for (i, (r, text)) in results.iter_mut().zip(query_texts.iter()).enumerate() {
if r.oql.is_empty() {
r.oql = text.clone();
}
if r.name.is_empty() {
let base = queries
.get(i)
.map(|(q, _)| q)
.and_then(query::viz::default_view_name)
.unwrap_or_else(|| format!("q{}", i + 1));
let mut name = base.clone();
let mut n = 2;
while seen.contains(&name) {
name = format!("{base} ({n})");
n += 1;
}
r.name = name.clone();
seen.insert(name);
} else {
seen.insert(r.name.clone());
}
}
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let (m, n) = (a.len(), b.len());
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr = vec![0usize; n + 1];
for i in 1..=m {
curr[0] = i;
for j in 1..=n {
curr[j] = if a[i - 1] == b[j - 1] {
prev[j - 1]
} else {
1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
};
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
#[allow(dead_code)]
fn is_plain_class_from(q: &query::ast::Query) -> Option<&str> {
let spec = q.from.class_spec()?;
if spec.instanceof || spec.is_regex {
return None;
}
let name = spec.class_name.as_str();
if name.contains('*') || name.ends_with("[]") || name.is_empty() {
return None;
}
Some(name)
}
fn is_named_class_from(q: &query::ast::Query) -> Option<&str> {
let spec = q.from.class_spec()?;
if spec.is_regex {
return None;
}
let name = spec.class_name.as_str();
if name.contains('*') || name.ends_with("[]") || name.is_empty() {
return None;
}
Some(name)
}
fn annotate_missing_classes(
input: &str,
results: &mut [query::model::QueryResult],
queries: &[(query::ast::Query, query::plan::QueryPlan)],
) {
if results.len() != queries.len() {
return;
}
let has_candidate = results.iter().zip(queries.iter()).any(|(r, (q, _))| {
r.error.is_none() && r.row_count == 0 && is_named_class_from(q).is_some()
});
if !has_candidate {
return;
}
let Ok(p1) = Pass1::run(&crate::source::HprofSource::from(input), false) else {
return;
};
let names: std::collections::HashSet<String> = p1
.class_map
.values()
.filter_map(|ci| p1.strings.get(&ci.name_id).map(|s| s.replace('/', ".")))
.collect();
for (r, (q, _)) in results.iter_mut().zip(queries.iter()) {
if r.error.is_some() || r.row_count != 0 {
continue;
}
if let Some(name) = is_named_class_from(q) {
if !names.contains(name) {
let simple = name.rsplit('.').next().unwrap_or(name);
let lower = name.to_ascii_lowercase();
let simple_lower = simple.to_ascii_lowercase();
let prefix_len = simple_lower.len().min(6);
let dist_threshold = if simple_lower.len() <= 4 { 1 } else { 2 };
let mut candidates: Vec<&str> = names
.iter()
.filter(|n| {
if n.starts_with('[') {
return false;
}
let nl = n.to_ascii_lowercase();
let sn = n
.rsplit('.')
.next()
.unwrap_or(n.as_str())
.to_ascii_lowercase();
sn == simple_lower
|| nl.contains(&lower)
|| (prefix_len >= 4 && sn.starts_with(&simple_lower[..prefix_len]))
|| edit_distance(&sn, &simple_lower) <= dist_threshold
})
.map(|n| n.as_str())
.collect();
candidates.sort_unstable();
candidates.dedup();
candidates.truncate(4);
let hint = if candidates.is_empty() {
format!(
"no class named `{name}` in this dump \
(check the fully-qualified name, or use a `pkg.*` glob)"
)
} else {
format!(
"no class named `{name}` in this dump — did you mean: {}?",
candidates.join(", ")
)
};
append_note(r, &hint);
}
}
}
}
fn attach_viz(results: &mut [query::model::QueryResult], collected: &[CollectedQuery]) {
for (r, c) in results.iter_mut().zip(collected.iter()) {
if let Some(name) = &c.name {
if !name.is_empty() {
r.name = name.clone();
}
}
if let Some(spec) = &c.viz {
if let Some(name) = &spec.name {
if !name.is_empty() {
r.name = name.clone();
}
}
}
if let Some(w) = &c.warning {
append_note(r, w);
}
if r.error.is_some() {
continue;
}
let Some(spec) = &c.viz else { continue };
match query::viz::resolve_columns(spec, &r.columns, &r.rows) {
Ok(_) => r.viz = Some(spec.clone()),
Err(reason) => append_note(r, &reason),
}
}
}
fn append_note(r: &mut query::model::QueryResult, msg: &str) {
match &mut r.note {
Some(existing) => {
existing.push_str("; ");
existing.push_str(msg);
}
None => r.note = Some(msg.to_string()),
}
}
fn fmt_query_value(v: &query::model::QueryValue) -> String {
use query::model::QueryValue::*;
match v {
Null => "null".to_string(),
Bool(b) => b.to_string(),
Int(i) => i.to_string(),
Float(f) => f.to_string(),
Str(s) => s.clone(),
ObjRef { index, class, addr } => {
if let Some(a) = addr {
format!("{class}@0x{a:x}")
} else {
format!("{class}@{index}")
}
}
}
}
fn run_queries(input: &str, opts: AnalyzeOptions, json_out: bool) -> io::Result<()> {
let collected = collect_query_texts(&opts)?;
if collected.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no query given. Supply OQL with `--query \"SELECT ...\"` (repeatable), \
`--query-file <PATH>` (one per line), a `[[query]]` entry in \
`.hprof-analyzer.toml`, `--repl` (interactive shell), or `--server` \
(HTTP endpoint). See `hprof-analyzer query --help`.",
));
}
let query_texts: Vec<String> = collected.iter().map(|c| c.text.clone()).collect();
let parsed = parse_plan_queries(&query_texts, opts.query_path_depth)?;
let (flat, union_groups) = query::run::expand_union_queries(&parsed);
let uses_subqueries = parsed.iter().any(|(_, p)| {
p.from_subplan.is_some() || !p.in_subplans.is_empty() || !p.exists_subplans.is_empty()
});
let needs_full = parsed.iter().any(|(_, p)| {
!p.late_ops.is_empty()
|| p.needs.retained
|| p.needs.dominator_children
|| p.needs.ref_walk
|| p.needs.gc_roots
});
let mut query_results = if uses_subqueries {
query::run::run_single_dump(input, &parsed, opts.reachable_only)?
} else if needs_full {
run_oql_escalated(input, &flat, &union_groups, opts.reachable_only, &opts)?
} else {
let source_q = crate::source::HprofSource::from(input);
let p1 = pass1::Pass1::run(&source_q, false)?;
if p1.class_ids.len() > u32::MAX as usize {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"dump has {} objects, exceeding the {} (u32::MAX) limit of the \
analyzer's index scheme; cannot analyze",
p1.class_ids.len(),
u32::MAX
),
));
}
let needs_sv = flat.iter().any(|(_, p)| p.needs.string_values);
let addr_vec = if needs_sv {
query::run::id_map_to_addrs(&p1.id_map)
} else {
Vec::new()
};
let mut no_in_sets = std::collections::HashMap::new();
let mut no_exists_bools = std::collections::HashMap::new();
let (
g,
_inbound,
_fwd_off_c,
_fwd_tgt_c,
_in_c,
query_state,
refwalk_csr,
string_values,
_sv_trunc,
) = pass2::Pass2::build(
&source_q,
p1,
cvec::Codec::Deflate9,
&opts,
&flat,
&mut no_in_sets,
&mut no_exists_bools,
)?;
let rpo = opts.reachable_only.then(|| {
crate::rpo_dfs::rpo_dfs(g.n, &g.gc_root_indices, &g.fwd_offsets, &g.fwd_targets)
});
let flat_results = query::run::resume_with_string_values(
query_state,
&flat,
string_values,
refwalk_csr,
rpo.as_ref().map(|r| r.dfn.as_slice()),
&addr_vec,
None,
);
query::run::collapse_union_results(flat_results, &union_groups)
};
finalize_query_labels(&mut query_results, &query_texts, &parsed);
annotate_missing_classes(input, &mut query_results, &parsed);
attach_viz(&mut query_results, &collected);
if json_out {
match serde_json::to_string_pretty(&query_results) {
Ok(j) => println!("{j}"),
Err(e) => {
return Err(io::Error::other(format!(
"failed to serialize query results as JSON: {e}"
)));
}
}
return Ok(());
}
let mut out = String::new();
let mut had_error = false;
for r in query_results.iter() {
out.push_str(&format!("== {} ==\n", r.name));
if !r.oql.is_empty() {
out.push_str(&format!(" {}\n", r.oql));
}
if let Some(err) = &r.error {
had_error = true;
out.push_str(&format!("error: {err}\n\n"));
continue;
}
let headers: Vec<String> = r.columns.iter().map(|c| c.name.clone()).collect();
let body: Vec<Vec<String>> = r
.rows
.iter()
.map(|row| row.iter().map(fmt_query_value).collect())
.collect();
let ncols = headers.len();
let is_numeric: Vec<bool> = (0..ncols)
.map(|col| {
r.rows.iter().all(|row| {
matches!(
row.get(col),
Some(query::model::QueryValue::Int(_))
| Some(query::model::QueryValue::Float(_))
| Some(query::model::QueryValue::Null)
| None
)
}) && r.rows.iter().any(|row| {
matches!(
row.get(col),
Some(query::model::QueryValue::Int(_))
| Some(query::model::QueryValue::Float(_))
)
})
})
.collect();
let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
for row in &body {
for (i, cell) in row.iter().enumerate() {
if i < ncols {
widths[i] = widths[i].max(cell.chars().count());
}
}
}
let pad_left = |s: &str, w: usize| -> String {
let n = s.chars().count();
if n >= w {
s.to_string()
} else {
format!("{s}{}", " ".repeat(w - n))
}
};
let pad_right = |s: &str, w: usize| -> String {
let n = s.chars().count();
if n >= w {
s.to_string()
} else {
format!("{}{s}", " ".repeat(w - n))
}
};
let hdr_cells: Vec<String> = headers
.iter()
.enumerate()
.map(|(i, h)| {
if i + 1 < ncols {
pad_left(h, widths[i])
} else {
h.clone()
}
})
.collect();
out.push_str(&hdr_cells.join(" | "));
out.push('\n');
let sep: Vec<String> = widths.iter().map(|&w| "-".repeat(w)).collect();
out.push_str(&sep.join("-+-"));
out.push('\n');
for row in &body {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(i, cell)| {
if i + 1 < ncols {
if i < ncols && is_numeric[i] {
pad_right(cell, widths[i])
} else {
pad_left(cell, widths[i])
}
} else {
cell.clone()
}
})
.collect();
out.push_str(&cells.join(" | "));
out.push('\n');
}
let plural = if r.row_count == 1 { "row" } else { "rows" };
let trunc = if r.truncated { ", truncated" } else { "" };
out.push_str(&format!("({} {}{})\n", r.row_count, plural, trunc));
if let Some(note) = &r.note {
out.push_str(&format!("note: {note}\n"));
}
out.push('\n');
}
print!("{out}");
if had_error {
return Err(io::Error::other(
"one or more queries returned an error (see output above)",
));
}
Ok(())
}
fn emit_truncated_header_report(
input: &str,
output: Option<&str>,
format: OutputFormat,
) -> io::Result<()> {
use report::{Report, SCHEMA_VERSION, SystemOverview};
let source_name = std::path::Path::new(input)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(input)
.to_string();
let r = Report {
schema_version: SCHEMA_VERSION,
generated: report::format::now_iso8601(),
truncated_input: true,
overview: SystemOverview {
file_path: input.to_string(),
source_name,
..Default::default()
},
..Default::default()
};
let text = match format {
OutputFormat::Md => report::render_markdown(&r),
OutputFormat::MdGraphs => report::render_markdown_graphs(&r),
OutputFormat::Json => serde_json::to_string_pretty(&r).map_err(io::Error::other)?,
OutputFormat::Html => html::render_html(&r),
};
progress::done();
write_output(output, &text)
}
fn run(
input: &str,
output: Option<&str>,
format: OutputFormat,
verbose: bool,
compress: cvec::Codec,
opts: AnalyzeOptions,
mat: Option<mat::MatEmitter>,
) -> io::Result<()> {
let t_total = Instant::now();
let t = Instant::now();
progress::phase("scanning dump (pass 1)");
let source = crate::source::HprofSource::from(input);
let p1 = match pass1::Pass1::run(&source, mat.is_some()) {
Ok(p1) => p1,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof && could_be_truncated_hprof(input) => {
return emit_truncated_header_report(input, output, format);
}
Err(e) => return Err(e),
};
let truncated_input = p1.truncated_input;
log(verbose, "pass1", t.elapsed().as_secs_f64());
if p1.class_ids.len() > u32::MAX as usize {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"dump has {} objects, exceeding the {} (u32::MAX) limit of the \
analyzer's index scheme; cannot analyze",
p1.class_ids.len(),
u32::MAX
),
));
}
if p1.class_ids.is_empty() {
eprintln!(
"warning: '{input}' contains no heap objects — \
the file may be truncated before the heap dump segment"
);
}
let collected = collect_query_texts(&opts)?;
let query_texts: Vec<String> = collected.iter().map(|c| c.text.clone()).collect();
let parsed_queries = parse_plan_queries(&query_texts, opts.query_path_depth)?;
if parsed_queries
.iter()
.any(|(_, p)| p.from_subplan.is_some() || !p.in_subplans.is_empty())
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"subqueries (FROM (...) / IN (...)) are only supported via the `query` \
subcommand, which re-scans the dump; they are not available in the \
full report. Run the query with `hprof-analyzer query <dump> -e '<oql>'`.",
));
}
let run_flags = {
let queries: Vec<query::ast::Query> =
parsed_queries.iter().map(|(q, _)| q.clone()).collect();
query::runflags::plan_run(&queries, &NoClassIndex, opts.query_path_depth).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("OQL edge planning error: {}", e.0),
)
})?
};
let (flat_queries, union_groups) = query::run::expand_union_queries(&parsed_queries);
let t = Instant::now();
progress::phase("building object graph (pass 2)");
let mat_class_meta: Option<mat::MatClassMeta> = if mat.is_some() {
Some(mat::MatClassMeta::from_pass1(&p1))
} else {
None
};
let mat_hprof_offsets_c: Option<cvec::CompressedBytes> = if mat.is_some() {
let bytes: Vec<u8> = {
let v = &p1.hprof_offsets;
let mut b = Vec::with_capacity(v.len() * 8);
for &off in v {
b.extend_from_slice(&off.to_le_bytes());
}
b
};
Some(cvec::CompressedBytes::compress(bytes, compress)?)
} else {
None
};
let mut no_in_sets = std::collections::HashMap::new();
let mut no_exists_bools = std::collections::HashMap::new();
let (
mut g,
mut inbound,
shallow_c,
class_idx_c,
alloc_serial_c,
mut query_state,
refwalk_csr,
string_values,
string_values_truncated,
) = pass2::Pass2::build(
&source,
p1,
compress,
&opts,
&flat_queries,
&mut no_in_sets,
&mut no_exists_bools,
)?;
log(
verbose,
&format!("pass2 n={}", g.n),
t.elapsed().as_secs_f64(),
);
let row_src_by_slot = query_state.take_row_src_by_slot();
let t = Instant::now();
let (mat_coc_snapshot, mat_addrs_c): (
Option<std::collections::HashMap<u32, u32>>,
Option<cvec::CompressedU64>,
) = if mat.is_some() {
let id_map = inbound
.id_map
.as_ref()
.expect("id_map must be live before compress_id_map for MAT idx emit");
let addrs: Vec<u64> = (0..g.n).map(|i| id_map.addr_at(i)).collect();
let coc_snap = g.class_obj_class_idx.clone();
let addrs_c = cvec::CompressedU64::compress(&addrs, compress)?;
(Some(coc_snap), Some(addrs_c))
} else {
(None, None)
};
inbound.compress_id_map(compress)?;
log(verbose, "compress-cold", t.elapsed().as_secs_f64());
crate::trace::probe("main: after compress_id_map (before rpo_dfs)");
let t = Instant::now();
progress::phase("ordering objects (reverse post-order)");
let rpo = rpo_dfs::rpo_dfs(g.n, &g.gc_root_indices, &g.fwd_offsets, &g.fwd_targets);
crate::trace::probe("main: after rpo_dfs (before compress parent_pre)");
log(verbose, "rpo", t.elapsed().as_secs_f64());
crate::trace::trim();
{
let t = Instant::now();
progress::phase("retained size (unreachable forest)");
g.unreachable_retained = unreachable_retained::compute_unreachable_retained(
g.n,
&rpo.dfn,
&g.fwd_offsets,
&g.fwd_targets,
&shallow_c,
&class_idx_c,
g.class_names.len(),
&g.class_obj_class_idx,
&g.class_names,
)?;
crate::trace::probe("main: after unreachable_retained (fwd CSR + dfn still alive)");
log(verbose, "unreachable-retained", t.elapsed().as_secs_f64());
crate::trace::trim();
}
let mut rpo = rpo;
let parent_pre_count = rpo.parent_pre.len();
let parent_pre_c = if compress != cvec::Codec::None {
let c = cvec::CompressedU32::compress(&rpo.parent_pre, compress)?;
crate::trace::drop_vec(std::mem::take(&mut rpo.parent_pre));
Some(c)
} else {
None
};
crate::trace::probe("main: after compress parent_pre (before inbound)");
let t = Instant::now();
progress::phase("building inbound references");
let want_forward = run_flags.retain_forward || run_flags.outbounds_by_rescan;
let want_inbound = run_flags.retain_inbound;
let (retained_edges, retained_inbound): RetainedEdgeStructs = if want_forward || want_inbound {
let edge_froms: Vec<(String, bool)> = flat_queries
.iter()
.filter(|(q, _)| query_uses_edges(q))
.map(|(q, _)| (q.from.class_name().to_string(), q.from.instanceof()))
.collect();
let class_idx_restored: Option<Vec<u32>> = if compress != cvec::Codec::None {
Some(class_idx_c.restore()?)
} else {
None
};
let class_idx_ref: &[u32] = class_idx_restored
.as_deref()
.unwrap_or(g.class_idx.as_slice());
let node_matches = |s: usize| -> bool {
let cn = &g.class_names[class_idx_ref[s] as usize];
edge_froms
.iter()
.any(|(pat, _inst)| query::execute::class_name_matches(cn, pat))
};
let n = g.n;
let fwd_off = &g.fwd_offsets;
let fwd_tgt = &g.fwd_targets;
let retained_edges = if want_forward {
let mut builder = crate::query::retained_edges::RetainedEdgesBuilder::new();
let mut scratch: Vec<u32> = Vec::new();
for s in 0..n {
if !node_matches(s) {
continue;
}
let (lo, hi) = (fwd_off[s] as usize, fwd_off[s + 1] as usize);
fwd_tgt.copy_range(lo, hi, &mut scratch);
scratch.sort_unstable();
builder.push_row(s as u32, &scratch);
}
Some(builder.finish())
} else {
None
};
let retained_inbound = if want_inbound {
let mut in_off = vec![0u32; n + 1];
let mut row: Vec<u32> = Vec::new();
for s in 0..n {
let (lo, hi) = (fwd_off[s] as usize, fwd_off[s + 1] as usize);
fwd_tgt.copy_range(lo, hi, &mut row);
for &t in &row {
if node_matches(t as usize) {
in_off[t as usize + 1] += 1;
}
}
}
for i in 0..n {
in_off[i + 1] += in_off[i];
}
let total = in_off[n] as usize;
let mut in_tgt = vec![0u32; total];
let mut cursor = in_off.clone();
for s in 0..n {
let (lo, hi) = (fwd_off[s] as usize, fwd_off[s + 1] as usize);
fwd_tgt.copy_range(lo, hi, &mut row);
for &t in &row {
if node_matches(t as usize) {
let slot = &mut cursor[t as usize];
in_tgt[*slot as usize] = s as u32;
*slot += 1;
}
}
}
Some((in_off, in_tgt))
} else {
None
};
drop(class_idx_restored);
(retained_edges, retained_inbound)
} else {
(None, None)
};
let mat_fwd_snap: Option<(cvec::CompressedU32, cvec::CompressedU32)> = if mat.is_some() {
let class_idx: Vec<u32> = class_idx_c.restore()?;
let fwd_off_c = cvec::CompressedU32::compress(&g.fwd_offsets, compress)?;
let class_idx_c2 = cvec::CompressedU32::compress(&class_idx, compress)?;
drop(class_idx);
Some((fwd_off_c, class_idx_c2))
} else {
None
};
let mat_outbound_rescan_ctx: Option<crate::pass2::MatOutboundRescanCtx> = if mat.is_some() {
Some(inbound.take_for_outbound_rescan())
} else {
None
};
if opts.obj_graph {
g.class_idx = class_idx_c.restore()?;
let (pairs, pair_fields) = crate::pass2::capture_type_ref_graph(&g);
crate::trace::drop_vec(std::mem::take(&mut g.class_idx));
g.type_ref_pairs = Some(pairs);
g.type_ref_pair_fields = Some(pair_fields);
crate::trace::probe("main: after capture_type_ref_graph");
}
const OBJ_GRAPH_TOP_N: usize = 500_000;
if opts.obj_graph {
g.obj_graph_edges = Some(crate::pass2::capture_obj_graph_edges(
&g,
OBJ_GRAPH_TOP_N,
opts.report_size.edge_cap(),
));
crate::trace::probe("main: after capture_obj_graph_edges");
}
let field_stats_fwd_main: Option<(Vec<u32>, crate::chunkvec::ChunkU32)> = if opts.field_stats {
let total_edges = g.fwd_offsets.last().copied().unwrap_or(0) as usize;
let fwd_off_copy = g.fwd_offsets.clone();
let mut fwd_tgt_copy = crate::chunkvec::ChunkU32::zeroed(total_edges);
for i in 0..total_edges {
fwd_tgt_copy.set(i, g.fwd_targets.get(i));
}
Some((fwd_off_copy, fwd_tgt_copy))
} else {
None
};
let (inb_block_off, inb_data) = if mat.is_some() {
drop(std::mem::take(&mut g.fwd_targets));
drop(std::mem::take(&mut g.fwd_offsets)); inbound.build_mat_scan(
&rpo.dfn,
|_src, _fwd| Ok(()), )?
} else if inbound.total_inb > 1_000_000_000 {
progress::phase("building inbound references (rescan path)");
drop(std::mem::take(&mut g.fwd_targets));
drop(std::mem::take(&mut g.fwd_offsets));
crate::trace::trim();
crate::trace::probe("main: fwd dropped (rescan-inbound path, before inb_flat alloc)");
inbound.build_mat_scan(&rpo.dfn, |_src, _fwd| Ok(()))?
} else {
crate::trace::probe("main: before build_from_fwd call");
inbound.build_from_fwd(
std::mem::take(&mut g.fwd_offsets),
std::mem::take(&mut g.fwd_targets),
&rpo.dfn,
)?
};
log(verbose, "inbound", t.elapsed().as_secs_f64());
crate::trace::trim();
let count = parent_pre_count;
rpo.vertex = rpo_dfs::rebuild_vertex(&rpo.dfn, count);
crate::trace::probe("main: after rebuild_vertex (post-inbound, dfn live)");
let reach_dfn: Option<Vec<u32>> = if opts.reachable_only {
Some(rpo.dfn.clone())
} else {
None
};
let mat_dfn_save_c: Option<cvec::CompressedU32> = if mat.is_some() {
let v = std::mem::take(&mut rpo.dfn);
Some(cvec::CompressedU32::compress(&v, compress)?)
} else {
crate::trace::drop_vec(std::mem::take(&mut rpo.dfn));
None
};
crate::trace::trim();
if let Some(c) = parent_pre_c {
rpo.parent_pre = c.restore()?;
crate::trace::probe("main: after restore parent_pre (before dominator)");
}
let mat_vertex_save_c: Option<cvec::CompressedU32> = if mat.is_some() {
Some(cvec::CompressedU32::compress(&rpo.vertex, compress)?)
} else {
None
};
let t = Instant::now();
progress::phase("computing dominators");
g.idom =
dominator::compute_dominators(g.n, rpo, &g.gc_root_indices, &inb_block_off, &inb_data)?;
log(verbose, "dominator", t.elapsed().as_secs_f64());
crate::trace::probe("main: after dominator");
drop(inb_block_off);
let mat_idom_c: Option<cvec::CompressedU32> = if mat.is_some() {
let c = cvec::CompressedU32::compress(&g.idom, compress)?;
g.idom = Vec::new();
crate::trace::trim();
Some(c)
} else {
None
};
crate::trace::probe("main: after compress idom (before inb_data compress)");
let inb_data_c: cvec::CompressedBytes = if mat.is_some() {
cvec::CompressedBytes::compress(inb_data, compress)?
} else {
cvec::CompressedBytes::compress(inb_data, cvec::Codec::None)?
};
crate::trace::trim();
let mat_map: Option<mat::MatIdMap> = if let Some(addrs_c) = mat_addrs_c {
if let Some(ref c) = mat_idom_c {
g.idom = c.restore()?;
}
let addrs = addrs_c.restore()?;
let mm = mat::MatIdMap::build(g.n, &g.idom, |i| addrs[i]);
g.idom = Vec::new();
crate::trace::trim();
crate::trace::probe("main: after MatIdMap::build + drop(idom)");
if let Some(ref m) = mat {
m.emit_long_index_iter(
"idx",
std::iter::once(0i64).chain(
mm.sorted()
.iter()
.map(|&old_id| addrs[old_id as usize] as i64),
),
)?;
drop(addrs); crate::trace::probe("main: after emit idx + drop(addrs) (before o2hprof)");
} else {
drop(addrs);
}
Some(mm)
} else {
None
};
if let (Some(m), Some(mm)) = (mat.as_ref(), mat_map.as_ref()) {
if let Some(off_c) = mat_hprof_offsets_c {
let offsets_bytes = off_c.restore()?;
m.emit_long_index_iter(
"o2hprof",
std::iter::once(0i64).chain(mm.sorted().iter().map(|&old_id| {
let lo = old_id as usize * 8;
offsets_bytes
.get(lo..lo + 8)
.and_then(|s| s.try_into().ok())
.map(i64::from_le_bytes)
.unwrap_or(0)
})),
)?;
drop(offsets_bytes);
}
} else {
drop(mat_hprof_offsets_c);
}
crate::trace::probe("main: after emit o2hprof");
crate::trace::probe("main: before mat_inv (free_addrs done at idx emission)");
let mut mat_inv: Option<Vec<i32>> =
if let (Some(mm), Some(coc)) = (mat_map.as_ref(), mat_coc_snapshot.as_ref()) {
Some(mat::build_row_to_classobj_id(coc, g.class_names.len(), mm))
} else {
None
};
if let (Some(ref mut inv), Some(ref mm)) = (mat_inv.as_mut(), mat_map.as_ref()) {
let mut name_to_coid: std::collections::HashMap<&str, i32> =
std::collections::HashMap::new();
for (row, name) in g.class_names.iter().enumerate() {
if inv[row] >= 0 {
name_to_coid.entry(name.as_str()).or_insert(inv[row]);
}
}
for (row, name) in g.class_names.iter().enumerate() {
if inv[row] < 0 {
if let Some(&coid) = name_to_coid.get(name.as_str()) {
inv[row] = coid;
}
}
}
let _ = mm; };
let mat_class_obj_ids_c: Option<cvec::CompressedU32> =
if let (Some(inv), Some(fwd_snap)) = (mat_inv.as_ref(), mat_fwd_snap.as_ref()) {
let class_idx_rows = fwd_snap.1.restore()?;
let result: Vec<u32> = class_idx_rows
.iter()
.map(|&row| {
let coid = inv[row as usize];
if coid < 0 { u32::MAX } else { coid as u32 }
})
.collect();
Some(cvec::CompressedU32::compress(&result, compress)?)
} else {
None
};
crate::trace::trim();
crate::trace::probe("main: before emit_outbound");
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
if let (Some((fwd_off_c, _class_idx_c)), Some(rescan_ctx)) =
(mat_fwd_snap.as_ref(), mat_outbound_rescan_ctx.as_ref())
{
let mut fwd_off = fwd_off_c.restore()?;
let total_edges = if !fwd_off.is_empty() {
fwd_off[fwd_off.len() - 1] as usize
} else {
0
};
let mut fwd_tgt: Vec<u32> = vec![0u32; total_edges];
crate::trace::probe("main: before outbound rescan (fwd_off+fwd_tgt allocated)");
crate::pass2::rescan_outbound(rescan_ctx, &mut fwd_off, &mut fwd_tgt)?;
crate::trace::probe("main: after outbound rescan");
crate::trace::trim();
let class_obj_ids = mat_class_obj_ids_c
.as_ref()
.expect("mat_class_obj_ids_c built when mat present")
.restore()?;
let n_entries = mm.mat_count();
let sorted = mm.sorted();
let mut idx = 0usize;
m.emit_outbound_cb(n_entries, |push| {
if idx == 0 {
idx += 1;
return Ok(());
}
let old_id = sorted[idx - 1];
idx += 1;
let lo = if old_id == 0 {
0
} else {
fwd_off[old_id as usize - 1] as usize
};
let hi = fwd_off[old_id as usize] as usize;
let coid = class_obj_ids[old_id as usize];
let class_mat = if coid == u32::MAX {
0i32
} else {
mm.translate(coid as i32).max(0)
};
let mut count = 0usize;
for i in lo..hi {
let mid = mm.translate(fwd_tgt[i] as i32);
if mid >= 0 {
fwd_tgt[lo + count] = mid as u32;
count += 1;
}
}
fwd_tgt[lo..lo + count].sort_unstable();
let class_u = class_mat as u32;
push(class_mat)?;
let mut prev = u32::MAX;
for &v in &fwd_tgt[lo..lo + count] {
if v != class_u && v != prev {
push(v as i32)?;
prev = v;
}
}
Ok(())
})?;
crate::trace::probe("main: after emit_outbound_cb (before drops)");
drop(fwd_tgt);
drop(fwd_off);
drop(class_obj_ids);
crate::trace::trim();
}
}
drop(mat_outbound_rescan_ctx);
drop(mat_fwd_snap);
drop(mat_class_obj_ids_c);
crate::trace::probe(
"main: after drop(mat_fwd_snap) — restore inb_data + build inb offset table",
);
let inb_data = inb_data_c.restore()?;
const INB_BLOCK_MAT: usize = 16;
let mat_inb_ctx: Option<(Vec<u32>, Vec<u64>, Vec<u32>)> =
if let (Some(dfn_c), Some(vertex_c)) = (mat_dfn_save_c, mat_vertex_save_c) {
let dfn = dfn_c.restore()?;
let vertex = vertex_c.restore()?;
let n = g.n;
let mut off: Vec<u64> = Vec::with_capacity(n / INB_BLOCK_MAT + 2);
let mut pos = 0usize;
for pre in 0..n {
if pre % INB_BLOCK_MAT == 0 {
off.push(pos as u64);
}
let (count, c0) = vbyte::decode_one(&inb_data[pos..]);
pos += c0;
for _ in 0..count {
let (_, c1) = vbyte::decode_one(&inb_data[pos..]);
pos += c1;
}
}
crate::trace::probe("main: after inb_pre_off build");
Some((dfn, off, vertex))
} else {
None
};
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
if let Some((dfn, inb_pre_off, vertex)) = mat_inb_ctx.as_ref() {
let iter = std::iter::once(Vec::new()) .chain(mm.sorted().iter().map(|&old_id| {
let pre = dfn[old_id as usize] as usize;
let block_start = pre - (pre % INB_BLOCK_MAT);
let mut pos = inb_pre_off[block_start / INB_BLOCK_MAT] as usize;
for _skip in block_start..pre {
let (skip_count, c0) = vbyte::decode_one(&inb_data[pos..]);
pos += c0;
for _ in 0..skip_count {
let (_, c1) = vbyte::decode_one(&inb_data[pos..]);
pos += c1;
}
}
let (count, c0) = vbyte::decode_one(&inb_data[pos..]);
pos += c0;
let mut e: Vec<i32> = Vec::with_capacity(count as usize);
let mut prev: u32 = 0;
for _ in 0..count {
let (delta, c1) = vbyte::decode_one(&inb_data[pos..]);
pos += c1;
prev += delta;
if prev > 0 {
let dense = vertex[prev as usize] as i32;
let mid = mm.translate(dense);
if mid >= 0 {
e.push(mid);
}
}
}
e.sort_unstable();
e
}));
m.emit_inbound_iter(iter)?;
crate::trace::trim();
}
}
drop(mat_inb_ctx);
if let Some(c) = mat_idom_c {
g.idom = c.restore()?;
}
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
let n_u = g.n as u32;
let mc = mm.mat_count();
let mut domin: Vec<i32> = Vec::with_capacity(mc);
domin.push(1i32);
for &old_id in mm.sorted() {
let d = g.idom[old_id as usize];
let mat_idom = if d == n_u || d == u32::MAX {
1i32
} else {
let mid = mm.translate(d as i32);
if mid < 0 { 1i32 } else { mid + 2 }
};
domin.push(mat_idom);
}
m.emit_dom_in(&domin)?;
}
drop(inb_data);
crate::trace::trim();
crate::trace::probe("main: before build_dom_children_csr");
let (dc_off, dc_tgt) = retained::build_dom_children_csr(g.n, &g.idom);
crate::trace::probe("main: after build_dom_children_csr");
let non_mat_idom_c: Option<cvec::CompressedU32> =
if mat.is_none() && compress != cvec::Codec::None {
let c = cvec::CompressedU32::compress(&g.idom, compress)?;
g.idom = Vec::new();
crate::trace::trim();
Some(c)
} else {
None
};
crate::trace::probe(
"main: after compress idom (non-MAT path, before restore shallow/class_idx)",
);
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
let n = g.n;
let lo0 = dc_off[n] as usize;
let hi0 = dc_off[n + 1] as usize;
let vroot_children: Vec<i32> = dc_tgt[lo0..hi0]
.iter()
.filter_map(|&v| {
let mid = mm.translate(v as i32);
if mid >= 0 { Some(mid) } else { None }
})
.collect();
let iter = std::iter::once(vroot_children)
.chain(std::iter::once(Vec::new())) .chain(mm.sorted().iter().map(|&old_id| {
let lo = dc_off[old_id as usize] as usize;
let hi = dc_off[old_id as usize + 1] as usize;
dc_tgt[lo..hi]
.iter()
.filter_map(|&v| {
let mid = mm.translate(v as i32);
if mid >= 0 { Some(mid) } else { None }
})
.collect::<Vec<i32>>()
}));
m.emit_dom_out_iter(iter)?;
crate::trace::trim();
}
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
let inv = mat_inv.as_ref().expect("mat_inv built when mat present");
let class_idx_vec: Vec<u32> = class_idx_c.restore()?;
let shallow_vec: Vec<u32> = shallow_c.restore()?;
let mc = mm.mat_count();
let mut o2c_vals: Vec<i32> = Vec::with_capacity(mc);
o2c_vals.push(0i32);
for &old_id in mm.sorted() {
let row = class_idx_vec[old_id as usize];
let class_obj_old = inv[row as usize]; let class_obj_mat = mm.translate(class_obj_old);
o2c_vals.push(if class_obj_mat >= 0 { class_obj_mat } else { 0 });
}
drop(class_idx_vec);
m.emit_int_index("o2c", &o2c_vals)?;
drop(o2c_vals);
let mut a2s_vals: Vec<i32> = Vec::with_capacity(mc);
a2s_vals.push(0i32);
for &old_id in mm.sorted() {
let sz = shallow_vec[old_id as usize] as i64;
a2s_vals.push(mat::size_compress(sz));
}
drop(shallow_vec);
m.emit_int_index("a2s", &a2s_vals)?;
}
if compress != cvec::Codec::None {
g.shallow = shallow_c.restore()?;
g.class_idx = class_idx_c.restore()?;
}
drop(shallow_c);
drop(class_idx_c);
crate::trace::probe("main: after restore shallow/class_idx");
let t = Instant::now();
progress::phase("computing retained sizes");
let class_count = g.class_names.len();
let (retained, has_same, depth_counts) = retained::compute_retained(
g.n,
&g.shallow,
&g.class_idx,
class_count,
&g.class_obj_class_idx,
&dc_off,
&dc_tgt,
);
g.retained = retained;
g.has_same_class_ancestor = has_same;
log(verbose, "retained", t.elapsed().as_secs_f64());
let precomputed_field_stats_main: Option<crate::report::FieldStats> =
if let Some((fwd_off, fwd_tgt)) = field_stats_fwd_main {
g.fwd_offsets = fwd_off;
g.fwd_targets = fwd_tgt;
let fs = crate::report::build_field_stats(&g);
g.fwd_offsets = Vec::new();
g.fwd_targets = crate::chunkvec::ChunkU32::default();
Some(fs)
} else {
None
};
let query_asts: Vec<query::ast::Query> = flat_queries.iter().map(|(q, _)| q.clone()).collect();
let id_map = query::stage_runner::IdMap::new(&[]);
let rw_off: &[u32] = refwalk_csr.as_ref().map_or(&[], |c| &c.fwd_off);
let rw_tgt: &[u32] = refwalk_csr.as_ref().map_or(&[], |c| &c.fwd_tgt);
let rw_field: &[u32] = refwalk_csr.as_ref().map_or(&[], |c| &c.fwd_field);
let rw_names: &[String] = refwalk_csr.as_ref().map_or(&[], |c| &c.field_names);
let rw_tails = refwalk_csr
.as_ref()
.map_or(&*query::stage_runner::EMPTY_REFWALK_TAILS, |c| &c.tails);
let rw_trunc = refwalk_csr.as_ref().is_some_and(|c| c.truncated);
let in_off: &[u32] = retained_inbound.as_ref().map_or(&[], |(o, _)| o);
let in_tgt: &[u32] = retained_inbound.as_ref().map_or(&[], |(_, t)| t);
let sv_ref: &std::collections::HashMap<u32, String> = if string_values.is_empty() {
&query::stage_runner::EMPTY_STRING_VALUES
} else {
&string_values
};
let gc_root_tags: std::collections::HashMap<u32, u8> =
if flat_queries.iter().any(|(_, p)| p.needs.gc_roots) {
g.gc_root_indices
.iter()
.zip(g.gc_root_types.iter())
.map(|(&idx, &ty)| (idx, ty))
.collect()
} else {
std::collections::HashMap::new()
};
let gc_root_tags_ref: &std::collections::HashMap<u32, u8> = if gc_root_tags.is_empty() {
&query::stage_runner::EMPTY_GC_ROOT_TAGS
} else {
&gc_root_tags
};
let flat_results = query::stage_runner::resume(
query_state,
&query_asts,
&query::stage_runner::LateCtx {
retained: &g.retained,
idom: &g.idom,
dc_off: &dc_off,
dc_tgt: &dc_tgt,
shallow: &g.shallow,
id_map: &id_map,
fwd_off: rw_off,
fwd_tgt: rw_tgt,
fwd_field: rw_field,
field_names: rw_names,
refwalk_tails: rw_tails,
refwalk_truncated: rw_trunc,
in_off,
in_tgt,
retained_edges: retained_edges.as_ref(),
string_values: sv_ref,
string_values_truncated,
gc_root_tags: gc_root_tags_ref,
class_idx: &g.class_idx,
class_names: &g.class_names,
},
);
let mut flat_results = flat_results;
if let Some(dfn) = &reach_dfn {
for (slot, r) in flat_results.iter_mut().enumerate() {
let row_expanding = flat_queries.get(slot).is_some_and(|(_, p)| {
p.late_ops.iter().any(|op| {
matches!(
op,
query::plan::StageOp::RetainedSet { .. }
| query::plan::StageOp::DominatorChildren { .. }
| query::plan::StageOp::DominatorOf
| query::plan::StageOp::EdgeLookup { .. }
| query::plan::StageOp::BoundedPath { .. }
)
})
});
if row_expanding {
continue;
}
if let Some(src) = row_src_by_slot.get(&slot) {
query::run::filter_result_by_src(r, src, dfn);
}
}
}
let mut query_results = query::run::collapse_union_results(flat_results, &union_groups);
debug_assert_eq!(query_results.len(), parsed_queries.len());
if let Some(note) = run_flags.retention_note() {
for (r, (q, _)) in query_results.iter_mut().zip(parsed_queries.iter()) {
if query_uses_edges(q) && r.note.is_none() {
r.note = Some(note.clone());
}
}
}
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
let mc = mm.mat_count();
let mut o2ret_vals: Vec<i64> = Vec::with_capacity(mc);
o2ret_vals.push(0i64); for &old_id in mm.sorted() {
o2ret_vals.push(g.retained[old_id as usize] as i64);
}
m.emit_long_index("o2ret", &o2ret_vals)?;
}
if let Some(ref m) = mat {
let mm = mat_map.as_ref().expect("mat_map built with mat");
let inv = mat_inv.as_ref().expect("mat_inv built when mat present");
let num_rows = g.class_names.len();
let mut per_class_retained: Vec<i64> = vec![0i64; num_rows];
for i in 0..g.n {
if g.idom[i] != u32::MAX {
let row = g.class_idx[i] as usize;
if row < num_rows {
per_class_retained[row] += g.retained[i] as i64;
}
}
}
let class_iter = (0..num_rows).filter_map(|row| {
let old_cobj = inv[row];
if old_cobj < 0 {
return None;
}
let mat_cid = mm.translate(old_cobj);
if mat_cid <= 0 {
return None;
}
Some((mat_cid, per_class_retained[row]))
});
m.emit_i2sv2(class_iter)?;
m.emit_threads(&g.thread_stacks, mm, &g.thread_local_frame_samples)?;
if let Some(ref meta) = mat_class_meta {
m.emit_dot_index(
meta,
&g.class_names,
&g.class_loader_id,
&g.class_obj_class_idx,
inv,
mm,
g.n,
&g.shallow,
&g.class_idx,
)?;
}
}
let alloc_sites = if opts.skip_report {
None
} else if let Some(c) = alloc_serial_c {
let mut agg = report::AllocAgg::new(&g, opts.alloc_sites_top);
c.for_each_u32(|serial| agg.push(serial))?;
let a = agg.finish();
g.alloc_frames_by_serial = None;
crate::trace::trim();
Some(a)
} else {
let a = report::build_alloc_sites(&g, opts.alloc_sites_top);
g.alloc_stack_serial = Vec::new();
g.alloc_frames_by_serial = None;
Some(a)
};
if opts.skip_report {
drop(dc_off);
drop(dc_tgt);
log(verbose, "total", t_total.elapsed().as_secs_f64());
return Ok(());
}
let t = Instant::now();
progress::phase("building report");
if let Some(c) = non_mat_idom_c {
g.idom = c.restore()?;
}
crate::trace::probe("report: before build_model");
let mut report = report::build_model(
&mut g,
dc_off,
dc_tgt,
opts.leak_children_cap,
&depth_counts,
&opts,
alloc_sites,
precomputed_field_stats_main,
);
crate::trace::probe("report: after build_model");
g.has_same_class_ancestor = crate::bitset::Bitset::default(); crate::trace::trim();
finalize_query_labels(&mut query_results, &query_texts, &parsed_queries);
attach_viz(&mut query_results, &collected);
report.queries = std::mem::take(&mut query_results);
report.truncated_input = truncated_input;
let out_text = match format {
OutputFormat::Md => {
let md = report::render_markdown(&report);
crate::trace::probe("report: after render_markdown");
md
}
OutputFormat::MdGraphs => {
let md = report::render_markdown_graphs(&report);
crate::trace::probe("report: after render_markdown_graphs");
md
}
OutputFormat::Json => {
let js = serde_json::to_string_pretty(&report).map_err(io::Error::other)?;
crate::trace::probe("report: after serialize_json");
js
}
OutputFormat::Html => {
let h = if opts.dev_report {
html::render_html_dev(&report, opts.bundle_path.as_deref())
} else {
html::render_html(&report)
};
crate::trace::probe("report: after render_html");
h
}
};
log(verbose, "report", t.elapsed().as_secs_f64());
progress::done();
write_output(output, &out_text).map_err(|e| {
let target = output.unwrap_or("<stdout>");
io::Error::new(e.kind(), format!("cannot write '{target}': {e}"))
})?;
log(verbose, "total", t_total.elapsed().as_secs_f64());
Ok(())
}
fn dump_pass1_json(path: &str) -> io::Result<()> {
let p = Pass1::run(&crate::source::HprofSource::from(path), false)?;
let mut class_hist: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for (i, &cidx) in p.class_ids.iter().enumerate() {
if p.kind[i] != 0 && p.kind[i] != 3 {
continue;
}
let addr = p.class_addr_table[cidx as usize];
if let Some(ci) = p.class_map.get(&addr) {
let name = p
.strings
.get(&ci.name_id)
.cloned()
.unwrap_or_else(|| format!("unknown@{addr:#x}"));
*class_hist.entry(name).or_insert(0) += 1;
}
}
let mut unique_roots: std::collections::HashSet<u64> = std::collections::HashSet::new();
for &a in &p.gc_root_addrs {
unique_roots.insert(a);
}
print!("{{");
print!(r#""id_size":{}"#, p.id_size);
print!(r#","format":"{}""#, p.format);
print!(r#","instances":{}"#, p.instance_count);
print!(r#","obj_arrays":{}"#, p.obj_array_count);
print!(r#","prim_arrays":{}"#, p.prim_array_count);
print!(r#","classes":{}"#, p.class_dump_count);
print!(r#","gc_roots_total":{}"#, p.gc_root_addrs.len());
print!(r#","strings":{}"#, p.strings.len());
print!(r#","class_histogram":{{"#);
let mut first = true;
for (name, count) in &class_hist {
if !first {
print!(",");
}
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
print!(r#""{escaped}":{count}"#);
first = false;
}
print!("}}");
println!("}}");
Ok(())
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::Parser;
#[test]
fn query_path_depth_default_is_5() {
let cli = Cli::try_parse_from(["hprof-analyzer", "heap.hprof"]).unwrap();
assert_eq!(cli.query_path_depth, DEFAULT_QUERY_PATH_DEPTH);
assert_eq!(cli.query_path_depth, 5);
}
#[test]
fn query_path_depth_custom() {
let cli = Cli::try_parse_from(["hprof-analyzer", "heap.hprof", "--query-path-depth", "3"])
.unwrap();
assert_eq!(cli.query_path_depth, 3);
}
#[test]
fn query_path_depth_zero_errors() {
let err = Cli::try_parse_from(["hprof-analyzer", "heap.hprof", "--query-path-depth", "0"])
.err()
.expect("zero depth must be rejected");
let msg = err.to_string();
assert!(
msg.contains("must be > 0"),
"zero depth must error actionably, got: {msg}"
);
}
#[test]
fn query_path_depth_non_numeric_errors() {
let err =
Cli::try_parse_from(["hprof-analyzer", "heap.hprof", "--query-path-depth", "abc"])
.err()
.expect("non-numeric depth must be rejected");
assert!(!err.to_string().is_empty());
}
#[test]
fn query_subcommand_query_path_depth_zero_errors() {
let err = Cli::try_parse_from([
"hprof-analyzer",
"query",
"heap.hprof",
"--query-path-depth",
"0",
])
.err()
.expect("query subcommand zero depth must be rejected");
assert!(
err.to_string().contains("must be > 0"),
"query subcommand zero depth must error actionably: {err}"
);
}
#[test]
fn parse_query_path_depth_helper() {
assert_eq!(parse_query_path_depth("5").unwrap(), 5);
let zero = parse_query_path_depth("0").unwrap_err();
assert!(zero.contains("must be > 0"), "0 message: {zero}");
assert!(
parse_query_path_depth("abc").is_err(),
"non-numeric must error"
);
}
}