diff --git a/Cargo.lock b/Cargo.lock index 0f02210f..9c1f42a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -978,6 +987,7 @@ name = "ghostscope-dwarf" version = "0.1.6" dependencies = [ "anyhow", + "bincode", "cpp_demangle", "crc32fast", "dirs", @@ -993,6 +1003,7 @@ dependencies = [ "object 0.36.7", "rayon", "rustc-demangle", + "serde", "tempfile", "thiserror 2.0.12", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 428cb810..6ce9ed60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ crossterm = { version = "0.29", features = ["event-stream"] } clap = { version = "4.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +bincode = "1.3" toml = "0.8" dirs = "5.0" object = "0.36" diff --git a/config-zh.toml b/config-zh.toml index d8c36cb4..31ee837f 100644 --- a/config-zh.toml +++ b/config-zh.toml @@ -101,6 +101,15 @@ search_paths = [ # 也会继续使用该独立调试文件(会记录警告日志)。仅建议在排障或环境不规范时短期启用。 allow_loose_debug_match = false +[dwarf.analysis_cache] +# 读取由 --prepare 生成、与运行时目标无关的 DWARF 解析索引。PID filter、 +# 模块映射、CFI map 发布和 eBPF attach 状态都不会写入这里。 +enabled = true + +# 未设置时使用 $XDG_CACHE_HOME/ghostscope/analysis 或 +# ~/.cache/ghostscope/analysis。 +# directory = "/home/user/.cache/ghostscope/analysis" + [dwarf.debuginfod] # debuginfod 调试信息回退。 # 模式参考 GDB: diff --git a/config.toml b/config.toml index 0fd2f3af..131cd7cb 100644 --- a/config.toml +++ b/config.toml @@ -108,6 +108,16 @@ search_paths = [ # ad-hoc environments; may cause inaccurate symbol/line info. allow_loose_debug_match = false +[dwarf.analysis_cache] +# Read parsed, target-independent DWARF indices populated by --prepare. Runtime +# PID filters, module mappings, CFI map publication, and eBPF attachment are +# never stored here. +enabled = true + +# Leave unset to use $XDG_CACHE_HOME/ghostscope/analysis or +# ~/.cache/ghostscope/analysis. +# directory = "/home/user/.cache/ghostscope/analysis" + [dwarf.debuginfod] # debuginfod fallback for separate debug information. # This follows GDB-style modes: diff --git a/docs/configuration.md b/docs/configuration.md index 95270081..769f8cb0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,6 +127,28 @@ ghostscope -p 1234 --debug-file /path/to/binary.debug # directories that you still rely on. ``` +### Preparing the Analysis Cache + +```bash +# Runs without root or eBPF capabilities. +ghostscope --prepare --target /path/to/binary + +# Use the same explicit debug file and cache directory during prepare and run. +ghostscope --prepare -t /path/to/binary -d /path/to/binary.debug \ + --analysis-cache-dir /var/cache/ghostscope +ghostscope -p 1234 -t /path/to/binary --script-file trace.gs \ + --analysis-cache-dir /var/cache/ghostscope +``` + +Prepare stores parsed DWARF, source-line, and symbol indices. A tracing run still +resolves PID namespaces, module load addresses, `dlopen` changes, CFI map state, +kernel capabilities, and eBPF attachment at runtime. Cache entries are rejected +when the target or selected debug file identity changes. + +CLI startup reports and the TUI loading summary show cache hits, misses, and +rejected entries. Misses and rejected entries are non-fatal: GhostScope parses +the original DWARF data, while rejected entries also include a fallback reason. + ### Logging Configuration ```bash @@ -242,6 +264,9 @@ Behavior: | `--backtrace-depth ` | | Max DWARF-unwound frames captured by each `bt`/`backtrace` instruction (`1..=128`) | 128 | | `--dry-run` | | Compile the script, resolve trace targets, and exit without attaching uprobes. Requires the same eBPF privileges and kernel capabilities as a real run. | Off | | `--dry-run-details` | | Include source, inline, and variable diagnostics in dry-run output; requires `--dry-run` | Off | +| `--prepare` | | Parse `--target` debug data into the persistent analysis cache and exit. Does not require eBPF privileges. | Off | +| `--analysis-cache-dir ` | | Override the parsed DWARF analysis cache directory | User cache directory | +| `--no-analysis-cache` | | Disable persistent parsed DWARF analysis caching | Off | | `--status` | | Enable interactive DWARF/script/attach stderr status prompts | On | | `--no-status` | | Disable interactive DWARF/script/attach stderr status prompts | Off override | | `--script-timestamp ` | | Pretty output timestamp: local, boot, none | local | @@ -372,6 +397,16 @@ search_paths = [ # this off unless you know what you are doing. allow_loose_debug_match = false +[dwarf.analysis_cache] +# Read target-independent DWARF, source-line, and symbol indices populated by +# --prepare. Runtime PID filters, ASLR/module ranges, CFI map publication, and +# eBPF attach state are resolved again for every tracing session. +enabled = true + +# Leave unset to use $XDG_CACHE_HOME/ghostscope/analysis or +# ~/.cache/ghostscope/analysis. +# directory = "/home/user/.cache/ghostscope/analysis" + [dwarf.debuginfod] # Optional debuginfod fallback for separate debug information. # Modes follow GDB's model: diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index b23e4a16..a77ccf30 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -126,6 +126,27 @@ ghostscope -p 1234 --debug-file /path/to/binary.debug # 注意:如果在 config.toml 中覆盖 search_paths,请保留仍然依赖的系统调试目录。 ``` +### 预生成分析缓存 + +```bash +# 不需要 root 或 eBPF capabilities。 +ghostscope --prepare --target /path/to/binary + +# prepare 和实际运行使用相同的显式 debug file 与缓存目录。 +ghostscope --prepare -t /path/to/binary -d /path/to/binary.debug \ + --analysis-cache-dir /var/cache/ghostscope +ghostscope -p 1234 -t /path/to/binary --script-file trace.gs \ + --analysis-cache-dir /var/cache/ghostscope +``` + +Prepare 保存解析后的 DWARF、源码行和符号索引。实际 tracing 时仍会解析 PID +namespace、模块加载地址、`dlopen` 变化、CFI map 状态、内核能力和 eBPF attach。 +目标文件或选中的 debug file 身份变化后,旧缓存会被拒绝。 + +CLI 启动报告和 TUI 加载摘要会显示 cache hit、miss 和 rejected 数量。miss 与 +rejected 都不会导致启动失败:GhostScope 会解析原始 DWARF;rejected 还会显示 +fallback 原因。 + ### 日志配置 ```bash @@ -241,6 +262,9 @@ ghostscope bpffs prune --dry-run --json | `--backtrace-depth ` | | 每条 `bt`/`backtrace` 指令最多采集的 DWARF unwind 栈帧数(`1..=128`) | 128 | | `--dry-run` | | 编译脚本、解析 trace 目标,然后退出,不 attach uprobe。需要与真实运行相同的 eBPF 权限和内核能力。 | 关 | | `--dry-run-details` | | 在 dry-run 输出中包含源码、inline 和变量诊断;需要同时使用 `--dry-run` | 关 | +| `--prepare` | | 将 `--target` 的调试数据解析到持久化分析缓存后退出;不需要 eBPF 权限 | 关 | +| `--analysis-cache-dir ` | | 覆盖 DWARF 解析缓存目录 | 用户缓存目录 | +| `--no-analysis-cache` | | 禁用持久化 DWARF 解析缓存 | 关 | | `--status` | | 启用交互式 DWARF/脚本/attach stderr 状态提示 | 开 | | `--no-status` | | 禁用交互式 DWARF/脚本/attach stderr 状态提示 | 关闭覆盖 | | `--script-timestamp ` | | pretty 输出时间戳:local, boot, none | local | @@ -366,6 +390,16 @@ search_paths = [ # 候选只要包含可用 .debug_info,仍会报告为 debuglink。仅建议在排障或环境不规范时短期启用。 allow_loose_debug_match = false +[dwarf.analysis_cache] +# 读取由 --prepare 生成、与运行时目标无关的 DWARF、源码行和符号索引。 +# PID filter、ASLR/模块 ranges、CFI map 发布和 eBPF attach 状态会在每个 +# tracing session 中重新解析。 +enabled = true + +# 未设置时使用 $XDG_CACHE_HOME/ghostscope/analysis 或 +# ~/.cache/ghostscope/analysis。 +# directory = "/home/user/.cache/ghostscope/analysis" + [dwarf.debuginfod] # 可选的 debuginfod 调试信息回退。 # 模式参考 GDB: diff --git a/e2e-tests/tests/backtrace_execution.rs b/e2e-tests/tests/backtrace_execution.rs index 97f20635..cb8849e8 100644 --- a/e2e-tests/tests/backtrace_execution.rs +++ b/e2e-tests/tests/backtrace_execution.rs @@ -38,6 +38,46 @@ fn signal_backtrace_target(target: &TargetHandle, signal: &str) -> anyhow::Resul Ok(()) } +fn prepare_analysis_cache(binary_path: &Path, cache_dir: &Path) -> anyhow::Result { + let sandbox = common::sandbox::SandboxHandle::default_ghostscope()?; + anyhow::ensure!( + sandbox.is_host_backend(), + "analysis-cache backtrace prepare requires host GhostScope" + ); + let (program, sandbox_args) = sandbox.ghostscope_command()?; + anyhow::ensure!( + sandbox_args.is_empty(), + "host GhostScope command unexpectedly has sandbox arguments" + ); + let output = Command::new(program) + .args([ + OsString::from("--prepare"), + OsString::from("--target"), + binary_path.as_os_str().to_os_string(), + OsString::from("--analysis-cache-dir"), + cache_dir.as_os_str().to_os_string(), + OsString::from("--no-log"), + ]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + anyhow::ensure!( + output.status.success(), + "analysis cache prepare failed with status {}\nSTDOUT:\n{}\nSTDERR:\n{}", + output.status, + stdout, + stderr + ); + Ok(stdout) +} + +fn analysis_cache_entry_count(cache_dir: &Path) -> anyhow::Result { + Ok(fs::read_dir(cache_dir.join("v2"))? + .filter_map(Result::ok) + .filter(|entry| entry.path().is_file()) + .count()) +} + async fn run_hot_backtrace_with_depth( script: &str, depth: u8, @@ -1105,6 +1145,110 @@ trace cross_module_lib_leaf { Ok(()) } +#[tokio::test] +async fn test_prepared_main_cache_keeps_uncached_backtrace_modules_loadable() -> anyhow::Result<()> +{ + init(); + + let ghostscope_sandbox = common::sandbox::SandboxHandle::default_ghostscope()?; + let target_sandbox = common::sandbox::SandboxHandle::default_target()?; + if !ghostscope_sandbox.is_host_backend() || !target_sandbox.is_host_backend() { + println!("skipping prepared backtrace cache e2e outside host->host topology"); + return Ok(()); + } + + let binary_path = FIXTURES.get_test_binary("backtrace_cross_module_program")?; + let cache = tempfile::tempdir()?; + let prepare_stdout = prepare_analysis_cache(&binary_path, cache.path())?; + assert!( + prepare_stdout.contains("Prepared analysis cache for"), + "prepare should populate a new analysis cache\nSTDOUT:\n{prepare_stdout}" + ); + assert_eq!( + analysis_cache_entry_count(cache.path())?, + 1, + "preparing only the main executable should create one cache entry" + ); + + let script = r#" +trace cross_module_lib_leaf { + print "PREPARED_CROSS_MODULE_STACK"; + bt full; +} +"#; + let log_path = cache.path().join("ghostscope.log"); + let (count, stdout, stderr) = run_backtrace_binary_with_args_and_mapped_module( + &binary_path, + script, + 250, + 3, + vec![ + OsString::from("--backtrace-depth"), + OsString::from("5"), + OsString::from("--analysis-cache-dir"), + cache.path().as_os_str().to_os_string(), + OsString::from("--log"), + OsString::from("--log-console"), + OsString::from("--log-level"), + OsString::from("info"), + OsString::from("--log-file"), + log_path.into_os_string(), + ], + None, + Some("libbacktrace_cross_module.so"), + ) + .await?; + if count == 0 && stderr.contains("BPF_PROG_LOAD") { + return Ok(()); + } + + let main_cache_hit = stderr.lines().any(|line| { + line.contains("DWARF analysis cache hit for") + && line.contains("backtrace_cross_module_program") + && !line.contains("libbacktrace_cross_module.so") + }); + assert!( + main_cache_hit, + "runtime should consume the prepared main executable cache\nSTDERR:\n{stderr}" + ); + assert!( + !stderr.lines().any(|line| { + line.contains("DWARF analysis cache hit for") + && line.contains("libbacktrace_cross_module.so") + }), + "the shared library should remain uncached\nSTDERR:\n{stderr}" + ); + assert_eq!( + analysis_cache_entry_count(cache.path())?, + 1, + "runtime cache reads must not add an entry for the shared library" + ); + + let block = first_backtrace_block_after(&stdout, "PREPARED_CROSS_MODULE_STACK", 5)?; + assert_ordered_patterns( + block, + &[ + "#0 cross_module_lib_leaf", + "#1 cross_module_lib_probe", + "#2 cross_module_main_caller", + "#3 cross_module_main_loop", + "#4 main", + ], + )?; + assert!( + block.contains("[libbacktrace_cross_module.so+") + && block.contains("[backtrace_cross_module_program+"), + "backtrace should load CFI and symbols from both cached and uncached modules\nBLOCK:\n{block}" + ); + assert!( + !block.contains("stopped: no unwind rows for PC") + && !block.contains("stopped: unsupported CFI"), + "uncached module CFI should still be published\nBLOCK:\n{block}\nSTDERR:\n{stderr}" + ); + + Ok(()) +} + #[tokio::test] #[serial(backtrace_t_mode)] async fn test_t_mode_cross_module_backtrace_resolves_so_and_exe_frames() -> anyhow::Result<()> { diff --git a/e2e-tests/tests/startup_load_report_execution.rs b/e2e-tests/tests/startup_load_report_execution.rs index 2610202a..b1bc43ae 100644 --- a/e2e-tests/tests/startup_load_report_execution.rs +++ b/e2e-tests/tests/startup_load_report_execution.rs @@ -4,8 +4,11 @@ mod common; use anyhow::{bail, Context, Result}; use common::init; +use regex::Regex; use std::ffi::{OsStr, OsString}; use std::fs; +use std::os::unix::fs::{chown, MetadataExt, PermissionsExt}; +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use std::sync::OnceLock; @@ -34,6 +37,120 @@ color = "auto" enabled = "off" "#; +#[tokio::test] +#[serial_test::serial] +async fn test_prepare_analysis_cache_runs_without_root_and_reuses_entry() -> Result<()> { + init(); + + if !is_host_topology() { + println!("skipping analysis cache prepare e2e outside host->host topology"); + return Ok(()); + } + + let fixture = ensure_startup_report_fixture()?; + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .context("e2e-tests must have a workspace parent")?; + let unprivileged_identity = workspace_owner_identity(workspace_root)?; + let temp_dir = TempDir::new_in(workspace_root) + .context("failed to create analysis cache temp dir in workspace")?; + fs::set_permissions(temp_dir.path(), fs::Permissions::from_mode(0o755))?; + let sandbox = common::sandbox::SandboxHandle::default_ghostscope()?; + let (ghostscope_program, sandbox_args) = sandbox.ghostscope_command()?; + if !sandbox_args.is_empty() { + bail!("analysis cache prepare e2e expects a host ghostscope command"); + } + let public_ghostscope = temp_dir.path().join("ghostscope"); + let public_binary = temp_dir.path().join(EMBEDDED_BINARY); + fs::copy(PathBuf::from(ghostscope_program), &public_ghostscope)?; + fs::copy(&fixture.embedded_binary, &public_binary)?; + fs::set_permissions(&public_ghostscope, fs::Permissions::from_mode(0o755))?; + fs::set_permissions(&public_binary, fs::Permissions::from_mode(0o755))?; + let cache_dir = temp_dir.path().join("analysis-cache"); + fs::create_dir(&cache_dir)?; + fs::set_permissions(&cache_dir, fs::Permissions::from_mode(0o700))?; + if let Some((uid, gid)) = unprivileged_identity { + chown(&cache_dir, Some(uid), Some(gid))?; + } + + let first = run_prepare_analysis_cache_command( + &public_ghostscope, + &public_binary, + &cache_dir, + unprivileged_identity, + )?; + assert!( + first.status.success(), + "rootless analysis prepare failed with status {}\n{}", + first.status, + first.output + ); + assert_output_contains(&first.output, "Prepared analysis cache for"); + assert!( + cache_dir.join("v2").is_dir(), + "prepare did not create a versioned cache under {}", + cache_dir.display() + ); + + let second = run_prepare_analysis_cache_command( + &public_ghostscope, + &public_binary, + &cache_dir, + unprivileged_identity, + )?; + assert!( + second.status.success(), + "analysis cache reuse failed with status {}\n{}", + second.status, + second.output + ); + assert_output_contains(&second.output, "Reused analysis cache for"); + + let runtime = run_startup_report_command_for_binary( + &fixture, + &public_binary, + &[ + OsString::from("--analysis-cache-dir"), + cache_dir.as_os_str().to_os_string(), + ], + )?; + assert!( + runtime.status.success(), + "runtime cache consumption failed with status {}\n{}", + runtime.status, + runtime.output + ); + assert_plain_output_contains(&runtime.output, "analysis cache: 1 hit"); + + corrupt_analysis_cache_payload_length(&cache_dir)?; + let fallback = run_startup_report_command_for_binary( + &fixture, + &public_binary, + &[ + OsString::from("--analysis-cache-dir"), + cache_dir.as_os_str().to_os_string(), + ], + )?; + assert!( + fallback.status.success(), + "runtime should ignore a malformed cache and reparse with status {}\n{}", + fallback.status, + fallback.output + ); + let plain_fallback = output_without_ansi(&fallback.output); + assert!( + !plain_fallback.contains("analysis cache: 1 hit"), + "malformed cache must not be reported as a hit\n{}", + fallback.output + ); + assert_plain_output_contains(&fallback.output, "analysis cache: 1 rejected"); + assert_plain_output_contains(&fallback.output, "cache fallback:"); + assert_plain_output_contains(&fallback.output, "Failed to decode analysis cache metadata"); + assert_plain_output_contains(&fallback.output, "Dry run complete; no uprobes attached."); + + Ok(()) +} + #[tokio::test] #[serial_test::serial] async fn test_startup_report_shows_embedded_source() -> Result<()> { @@ -427,6 +544,98 @@ fn run_startup_report_command_for_binary_with_config( }) } +fn run_prepare_analysis_cache_command( + ghostscope: &Path, + binary: &Path, + cache_dir: &Path, + unprivileged_identity: Option<(u32, u32)>, +) -> Result { + let mut command = Command::new(ghostscope); + command.args([ + OsStr::new("--prepare"), + OsStr::new("--target"), + binary.as_os_str(), + OsStr::new("--analysis-cache-dir"), + cache_dir.as_os_str(), + OsStr::new("--no-log"), + ]); + command.current_dir("/tmp"); + if let Some(home) = cache_dir.parent() { + command.env("HOME", home); + } + if let Some((uid, gid)) = unprivileged_identity { + command.gid(gid).uid(uid); + } + let output = command + .output() + .context("failed to run rootless analysis cache prepare")?; + + let mut combined = String::new(); + combined.push_str(&String::from_utf8_lossy(&output.stdout)); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + Ok(StartupReportRun { + status: output.status, + output: combined, + }) +} + +fn workspace_owner_identity(workspace_root: &Path) -> Result> { + if unsafe { libc::geteuid() } != 0 { + return Ok(None); + } + + let metadata = fs::metadata(workspace_root).with_context(|| { + format!( + "failed to inspect workspace owner for {}", + workspace_root.display() + ) + })?; + if metadata.uid() == 0 { + bail!( + "rootless prepare e2e requires a workspace owned by a non-root user: {}", + workspace_root.display() + ); + } + Ok(Some((metadata.uid(), metadata.gid()))) +} + +fn corrupt_analysis_cache_payload_length(cache_dir: &Path) -> Result<()> { + let entries = fs::read_dir(cache_dir.join("v2"))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_file()) + .collect::>(); + if entries.len() != 1 { + bail!( + "expected one analysis cache entry under {}, found {}", + cache_dir.display(), + entries.len() + ); + } + + let entry = &entries[0]; + let mut encoded = fs::read(entry)?; + if encoded.len() < 24 { + bail!("analysis cache entry {} is too short", entry.display()); + } + let header_len = u32::from_le_bytes(encoded[12..16].try_into()?) as usize; + let string_length_offset = 24_usize + .checked_add(header_len) + .and_then(|offset| offset.checked_add(8)) + .context("analysis cache payload offset overflow")?; + let string_length_end = string_length_offset + .checked_add(8) + .context("analysis cache string length offset overflow")?; + if string_length_end > encoded.len() { + bail!( + "analysis cache entry {} has an invalid payload offset", + entry.display() + ); + } + encoded[string_length_offset..string_length_end].copy_from_slice(&u64::MAX.to_le_bytes()); + fs::write(entry, encoded)?; + Ok(()) +} + fn startup_report_config(debug_search_paths: &[&Path]) -> String { let mut config = String::from( r#" @@ -494,6 +703,22 @@ fn assert_output_contains(output: &str, needle: &str) { ); } +fn assert_plain_output_contains(output: &str, needle: &str) { + let plain_output = output_without_ansi(output); + assert!( + plain_output.contains(needle), + "expected plain output to contain {needle:?}\n{output}" + ); +} + +fn output_without_ansi(output: &str) -> String { + static ANSI_ESCAPE: OnceLock = OnceLock::new(); + ANSI_ESCAPE + .get_or_init(|| Regex::new(r"\x1B\[[0-?]*[ -/]*[@-~]").expect("valid ANSI regex")) + .replace_all(output, "") + .into_owned() +} + fn is_host_topology() -> bool { env_is_unset_or("E2E_GHOSTSCOPE_SANDBOX", "host") && env_is_unset_or("E2E_TARGET_SANDBOX", "host") diff --git a/ghostscope-dwarf/Cargo.toml b/ghostscope-dwarf/Cargo.toml index 13dd2720..01a76863 100644 --- a/ghostscope-dwarf/Cargo.toml +++ b/ghostscope-dwarf/Cargo.toml @@ -20,6 +20,8 @@ memmap2 = "0.9" anyhow.workspace = true tracing.workspace = true thiserror.workspace = true +serde.workspace = true +bincode.workspace = true ghostscope-platform = { version = "0.1.6", path = "../ghostscope-platform" } ghostscope-protocol = { version = "0.1.6", path = "../ghostscope-protocol" } ghostscope-process = { version = "0.1.6", path = "../ghostscope-process" } diff --git a/ghostscope-dwarf/src/analysis_cache.rs b/ghostscope-dwarf/src/analysis_cache.rs new file mode 100644 index 00000000..71c3b327 --- /dev/null +++ b/ghostscope-dwarf/src/analysis_cache.rs @@ -0,0 +1,1276 @@ +use crate::{ + binary::MappedFile, + core::{FunctionDieKind, IndexEntry, IndexFlags}, + index::{ + encode_mapped_line_row, LightweightFileIndex, LightweightIndex, LineMappingTable, + ScopedFileIndexManager, MAPPED_LINE_PATH_INDEX_SIZE, MAPPED_LINE_ROW_SIZE, + }, + parser::{CompilationUnit, DwarfParseResult, DwarfParseStats, SourceFile}, +}; +use anyhow::{Context, Result}; +use bincode::Options; +use memmap2::MmapOptions; +use object::Object; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs::{self, DirBuilder, OpenOptions}, + io::{BufWriter, Read, Write}, + os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, + panic::{catch_unwind, AssertUnwindSafe}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::UNIX_EPOCH, +}; + +const CACHE_MAGIC: [u8; 8] = *b"GSANLYS\0"; +const CACHE_SCHEMA_VERSION: u32 = 2; +const CACHE_FILE_PREFIX_LEN: usize = 24; +const MAX_CACHE_HEADER_BYTES: u64 = 64 * 1024; +const MAX_CACHE_PAYLOAD_BYTES: u64 = 512 * 1024 * 1024; +const MAX_CACHE_FILE_BYTES: u64 = + CACHE_FILE_PREFIX_LEN as u64 + MAX_CACHE_HEADER_BYTES + MAX_CACHE_PAYLOAD_BYTES; +static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone)] +pub struct AnalysisCache { + directory: PathBuf, + writable: bool, +} + +impl AnalysisCache { + pub fn new(directory: impl Into) -> Self { + Self { + directory: directory.into(), + writable: true, + } + } + + pub fn read_only(directory: impl Into) -> Self { + Self { + directory: directory.into(), + writable: false, + } + } + + pub fn default_directory() -> PathBuf { + dirs::cache_dir() + .or_else(|| dirs::home_dir().map(|home| home.join(".cache"))) + .unwrap_or_else(|| { + std::env::temp_dir().join(format!( + "ghostscope-{}", + // SAFETY: geteuid has no preconditions and does not mutate memory. + unsafe { libc::geteuid() } + )) + }) + .join("ghostscope") + .join("analysis") + } + + pub fn directory(&self) -> &Path { + &self.directory + } + + pub(crate) fn is_writable(&self) -> bool { + self.writable + } + + pub(crate) fn load( + &self, + binary: &MappedFile, + debug_file: &MappedFile, + ) -> Result> { + let identity = CacheIdentity::new(binary, debug_file)?; + let path = self.entry_path(&identity)?; + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to open analysis cache {}", path.display())); + } + }; + let metadata = file + .metadata() + .with_context(|| format!("Failed to inspect analysis cache {}", path.display()))?; + anyhow::ensure!( + metadata.is_file(), + "Analysis cache {} is not a regular file", + path.display() + ); + anyhow::ensure!( + metadata.len() <= MAX_CACHE_FILE_BYTES, + "Analysis cache {} is {} bytes, exceeding the {} byte limit", + path.display(), + metadata.len(), + MAX_CACHE_FILE_BYTES + ); + anyhow::ensure!( + metadata.len() >= CACHE_FILE_PREFIX_LEN as u64, + "Analysis cache {} is too short", + path.display() + ); + + let snapshot_len = usize::try_from(metadata.len())?; + let mut snapshot = MmapOptions::new() + .len(snapshot_len) + .map_anon() + .with_context(|| format!("Failed to allocate cache snapshot for {}", path.display()))?; + file.read_exact(&mut snapshot) + .with_context(|| format!("Failed to snapshot analysis cache {}", path.display()))?; + let data = + Arc::new(snapshot.make_read_only().with_context(|| { + format!("Failed to protect cache snapshot for {}", path.display()) + })?); + let prefix = data + .get(..CACHE_FILE_PREFIX_LEN) + .with_context(|| format!("Failed to read analysis cache header {}", path.display()))?; + anyhow::ensure!( + prefix[..CACHE_MAGIC.len()] == CACHE_MAGIC, + "Analysis cache {} has an invalid magic value", + path.display() + ); + + let schema_version = u32::from_le_bytes(prefix[8..12].try_into()?); + if schema_version != CACHE_SCHEMA_VERSION { + anyhow::bail!( + "Cache schema {schema_version} does not match supported schema \ + {CACHE_SCHEMA_VERSION}" + ); + } + let header_len = u64::from(u32::from_le_bytes(prefix[12..16].try_into()?)); + let payload_len = u64::from_le_bytes(prefix[16..24].try_into()?); + anyhow::ensure!( + header_len > 0 && header_len <= MAX_CACHE_HEADER_BYTES, + "Analysis cache {} has invalid header length {}", + path.display(), + header_len + ); + anyhow::ensure!( + payload_len > 0 && payload_len <= MAX_CACHE_PAYLOAD_BYTES, + "Analysis cache {} has invalid payload length {}", + path.display(), + payload_len + ); + let expected_len = (CACHE_FILE_PREFIX_LEN as u64) + .checked_add(header_len) + .and_then(|length| length.checked_add(payload_len)) + .context("Analysis cache length overflow")?; + anyhow::ensure!( + metadata.len() == expected_len, + "Analysis cache {} length mismatch: expected {}, found {}", + path.display(), + expected_len, + metadata.len() + ); + + let header_start = CACHE_FILE_PREFIX_LEN; + let header_len = usize::try_from(header_len)?; + let header_end = header_start + .checked_add(header_len) + .context("Analysis cache header offset overflow")?; + let header: CacheHeader = deserialize_cache_slice( + data.get(header_start..header_end) + .context("Analysis cache header is out of bounds")?, + "header", + &path, + )?; + if header.package_version != env!("CARGO_PKG_VERSION") { + anyhow::bail!( + "Cache was prepared by GhostScope {}, current version is {}", + header.package_version, + env!("CARGO_PKG_VERSION") + ); + } + if header.identity != identity { + anyhow::bail!("Cached target or debug-file identity does not match"); + } + + let metadata_len = usize::try_from(header.layout.metadata_len)?; + anyhow::ensure!(metadata_len > 0, "Analysis cache metadata is empty"); + let line_row_count = usize::try_from(header.layout.line_row_count)?; + let path_index_count = usize::try_from(header.layout.path_index_count)?; + let rows_len = line_row_count + .checked_mul(MAPPED_LINE_ROW_SIZE) + .context("Analysis cache line row length overflow")?; + let path_index_len = path_index_count + .checked_mul(MAPPED_LINE_PATH_INDEX_SIZE) + .context("Analysis cache line path index length overflow")?; + let calculated_payload_len = metadata_len + .checked_add(rows_len) + .and_then(|length| length.checked_add(path_index_len)) + .context("Analysis cache payload layout overflow")?; + anyhow::ensure!( + calculated_payload_len == usize::try_from(payload_len)?, + "Analysis cache payload layout does not match its encoded length" + ); + anyhow::ensure!( + path_index_count <= line_row_count, + "Analysis cache line path index has more entries than line rows" + ); + + let payload_start = header_end; + let metadata_end = payload_start + .checked_add(metadata_len) + .context("Analysis cache metadata offset overflow")?; + let rows_offset = metadata_end; + let path_index_offset = rows_offset + .checked_add(rows_len) + .context("Analysis cache line path index offset overflow")?; + let mut payload: CachedParseResult = deserialize_cache_slice( + data.get(payload_start..metadata_end) + .context("Analysis cache metadata is out of bounds")?, + "metadata", + &path, + )?; + let strings: Arc<[Arc]> = std::mem::take(&mut payload.strings) + .into_iter() + .map(Arc::::from) + .collect::>() + .into(); + let line_mapping = LineMappingTable::from_mapped_cache( + data, + rows_offset, + line_row_count, + path_index_offset, + path_index_count, + Arc::clone(&strings), + &payload.line_path_ids, + )?; + + payload.into_parse_result(&strings, line_mapping).map(Some) + } + + pub(crate) fn store( + &self, + binary: &MappedFile, + debug_file: &MappedFile, + parse_result: &DwarfParseResult, + ) -> Result { + let identity = CacheIdentity::new(binary, debug_file)?; + let path = self.entry_path(&identity)?; + let directory = path + .parent() + .context("Analysis cache entry has no parent directory")?; + create_private_directory(directory)?; + + let line_entries = parse_result.line_mapping.cache_entries(); + anyhow::ensure!( + line_entries.len() <= u32::MAX as usize, + "Analysis cache has too many line rows" + ); + let (payload, string_ids) = + CachedParseResult::from_parse_result(parse_result, &line_entries)?; + let line_string_ids = line_entries + .iter() + .map(|entry| { + Ok(( + cached_string_id(&string_ids, &entry.file_path)?, + cached_string_id(&string_ids, &entry.compilation_unit)?, + )) + }) + .collect::>>()?; + let mut path_order = line_entries + .iter() + .enumerate() + .filter(|(_, entry)| !entry.file_path.is_empty()) + .map(|(index, _)| u32::try_from(index)) + .collect::, _>>()?; + path_order.sort_unstable_by_key(|&row_index| { + let index = row_index as usize; + let entry = &line_entries[index]; + ( + line_string_ids[index].0, + entry.line, + entry.address, + row_index, + ) + }); + + let metadata_len = cache_codec().serialized_size(&payload)?; + let line_row_count = u64::try_from(line_entries.len())?; + let path_index_count = u64::try_from(path_order.len())?; + let rows_len = line_row_count + .checked_mul(u64::try_from(MAPPED_LINE_ROW_SIZE)?) + .context("Analysis cache line row length overflow")?; + let path_index_len = path_index_count + .checked_mul(u64::try_from(MAPPED_LINE_PATH_INDEX_SIZE)?) + .context("Analysis cache line path index length overflow")?; + let payload_len = metadata_len + .checked_add(rows_len) + .and_then(|length| length.checked_add(path_index_len)) + .context("Analysis cache payload length overflow")?; + let header = CacheHeader { + package_version: env!("CARGO_PKG_VERSION").to_string(), + identity, + layout: CachePayloadLayout { + metadata_len, + line_row_count, + path_index_count, + }, + }; + let header_len = cache_codec().serialized_size(&header)?; + anyhow::ensure!( + header_len > 0 && header_len <= MAX_CACHE_HEADER_BYTES, + "Analysis cache header is {header_len} bytes, exceeding the \ + {MAX_CACHE_HEADER_BYTES} byte limit" + ); + anyhow::ensure!( + payload_len > 0 && payload_len <= MAX_CACHE_PAYLOAD_BYTES, + "Analysis cache payload is {payload_len} bytes, exceeding the \ + {MAX_CACHE_PAYLOAD_BYTES} byte limit" + ); + let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temp_path = directory.join(format!( + ".{}.{}.{}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("analysis-cache"), + std::process::id(), + sequence + )); + + let write_result = (|| -> Result<()> { + let file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temp_path) + .with_context(|| { + format!( + "Failed to create temporary analysis cache {}", + temp_path.display() + ) + })?; + let mut writer = BufWriter::new(file); + writer.write_all(&CACHE_MAGIC)?; + writer.write_all(&CACHE_SCHEMA_VERSION.to_le_bytes())?; + writer.write_all(&u32::try_from(header_len)?.to_le_bytes())?; + writer.write_all(&payload_len.to_le_bytes())?; + cache_codec() + .serialize_into(&mut writer, &header) + .with_context(|| { + format!( + "Failed to encode analysis cache header {}", + temp_path.display() + ) + })?; + cache_codec() + .serialize_into(&mut writer, &payload) + .with_context(|| { + format!( + "Failed to encode analysis cache metadata {}", + temp_path.display() + ) + })?; + for (index, entry) in line_entries.iter().enumerate() { + let (path_id, compilation_unit_id) = line_string_ids[index]; + writer.write_all(&encode_mapped_line_row(entry, path_id, compilation_unit_id))?; + } + for row_index in &path_order { + writer.write_all(&row_index.to_le_bytes())?; + } + writer.flush()?; + writer.get_ref().sync_all()?; + fs::rename(&temp_path, &path).with_context(|| { + format!( + "Failed to publish analysis cache {} as {}", + temp_path.display(), + path.display() + ) + })?; + Ok(()) + })(); + + if write_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + write_result?; + Ok(path) + } + + fn entry_path(&self, identity: &CacheIdentity) -> Result { + let encoded = cache_codec().serialize(identity)?; + let hash = fnv1a64(&encoded); + Ok(self + .directory + .join(format!("v{CACHE_SCHEMA_VERSION}")) + .join(format!("{hash:016x}.bin"))) + } +} + +fn cache_codec() -> impl Options { + bincode::DefaultOptions::new() + .with_fixint_encoding() + .reject_trailing_bytes() +} + +fn deserialize_cache_slice( + encoded: &[u8], + part: &str, + path: &Path, +) -> Result { + let decoded = catch_unwind(AssertUnwindSafe(|| { + cache_codec() + .with_limit(encoded.len() as u64) + .deserialize(encoded) + })) + .map_err(|_| { + anyhow::anyhow!( + "Analysis cache {} decoder panicked while reading {}", + path.display(), + part + ) + })? + .with_context(|| { + format!( + "Failed to decode analysis cache {} {}", + part, + path.display() + ) + })?; + Ok(decoded) +} + +fn create_private_directory(path: &Path) -> Result<()> { + let mut builder = DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder.create(path).map_err(|error| { + anyhow::anyhow!( + "Failed to create analysis cache directory {}: {error}", + path.display() + ) + }) +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CacheIdentity { + binary: FileIdentity, + debug_file: FileIdentity, +} + +impl CacheIdentity { + fn new(binary: &MappedFile, debug_file: &MappedFile) -> Result { + Ok(Self { + binary: FileIdentity::new(binary)?, + debug_file: FileIdentity::new(debug_file)?, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct FileIdentity { + byte_len: u64, + build_id: Option>, + fallback_device: Option, + fallback_inode: Option, + fallback_ctime_secs: Option, + fallback_ctime_nanos: Option, + fallback_hash: Option, + modified_secs: Option, + modified_nanos: Option, +} + +impl FileIdentity { + fn new(file: &MappedFile) -> Result { + let build_id = file + .parse_object() + .ok() + .and_then(|object| object.build_id().ok().flatten().map(ToOwned::to_owned)); + let metadata = fs::metadata(&file.path).ok(); + let modified = metadata + .as_ref() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()); + let ( + fallback_device, + fallback_inode, + fallback_ctime_secs, + fallback_ctime_nanos, + fallback_hash, + ) = if build_id.is_some() { + (None, None, None, None, None) + } else if let Some(metadata) = metadata.as_ref() { + ( + Some(metadata.dev()), + Some(metadata.ino()), + Some(metadata.ctime()), + Some(metadata.ctime_nsec()), + None, + ) + } else { + (None, None, None, None, Some(fnv1a64(file.as_bytes()))) + }; + + Ok(Self { + byte_len: u64::try_from(file.as_bytes().len()) + .context("Mapped object length does not fit in u64")?, + build_id, + // Avoid a full-file hash on the startup path for objects without a + // build ID. Device, inode, size, mtime, and ctime identify the + // mapped file and invalidate the cache after in-place updates. A + // content hash remains as a fallback if the mapped path disappears. + fallback_device, + fallback_inode, + fallback_ctime_secs, + fallback_ctime_nanos, + fallback_hash, + modified_secs: modified.map(|duration| duration.as_secs()), + modified_nanos: modified.map(|duration| duration.subsec_nanos()), + }) + } +} + +#[derive(Serialize, Deserialize)] +struct CacheHeader { + package_version: String, + identity: CacheIdentity, + layout: CachePayloadLayout, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct CachePayloadLayout { + metadata_len: u64, + line_row_count: u64, + path_index_count: u64, +} + +#[derive(Serialize, Deserialize)] +struct CachedParseResult { + strings: Vec, + index_entries: Vec, + cu_ranges: Vec, + line_path_ids: Vec, + file_indices: Vec, + compilation_units: Vec<(u32, CachedCompilationUnit)>, + stats: CachedParseStats, +} + +impl CachedParseResult { + fn from_parse_result( + result: &DwarfParseResult, + line_entries: &[std::borrow::Cow<'_, crate::core::LineEntry>], + ) -> Result<(Self, HashMap)> { + let mut interner = StringInterner::default(); + for entry in result.lightweight_index.cache_entries() { + interner.intern(&entry.name)?; + } + + let mut file_indices = result + .scoped_file_manager + .cache_file_indices() + .collect::>(); + file_indices.sort_by_key(|(cu_name, _)| *cu_name); + for (cu_name, index) in &file_indices { + interner.intern(cu_name)?; + if let Some(comp_dir) = index.cache_comp_dir() { + interner.intern(comp_dir)?; + } + for directory in index.cache_directories() { + interner.intern(directory)?; + } + for entry in index.file_entries() { + interner.intern(&entry.filename)?; + } + } + + let mut compilation_units = result.compilation_units.iter().collect::>(); + compilation_units.sort_by_key(|(name, _)| *name); + for (name, unit) in &compilation_units { + interner.intern(name)?; + interner.intern(&unit.base_directory)?; + for directory in &unit.include_directories { + interner.intern(directory)?; + } + for file in &unit.files { + interner.intern(&file.directory_path)?; + interner.intern(&file.filename)?; + interner.intern(&file.full_path)?; + } + } + for entry in line_entries { + interner.intern(&entry.file_path)?; + interner.intern(&entry.compilation_unit)?; + } + + let mut line_path_ids = line_entries + .iter() + .filter(|entry| !entry.file_path.is_empty()) + .map(|entry| interner.id(&entry.file_path)) + .collect::>>()?; + line_path_ids.sort_unstable(); + line_path_ids.dedup(); + + let cached_file_indices = file_indices + .into_iter() + .map(|(cu_name, index)| CachedScopedFileIndex::from_index(cu_name, index, &interner)) + .collect::>>()?; + let cached_compilation_units = compilation_units + .into_iter() + .map(|(name, unit)| { + Ok(( + interner.id(name)?, + CachedCompilationUnit::from_unit(unit, &interner)?, + )) + }) + .collect::>>()?; + + let mut payload = Self { + strings: Vec::new(), + index_entries: result + .lightweight_index + .cache_entries() + .iter() + .map(|entry| CachedIndexEntry::from_index_entry(entry, &interner)) + .collect::>>()?, + cu_ranges: result + .lightweight_index + .cache_cu_ranges() + .into_iter() + .map(|(start, end, cu)| CachedCuRange { + start, + end, + cu_offset: cu.0 as u64, + }) + .collect(), + line_path_ids, + file_indices: cached_file_indices, + compilation_units: cached_compilation_units, + stats: CachedParseStats::from(&result.stats), + }; + let StringInterner { strings, ids } = interner; + payload.strings = strings; + Ok((payload, ids)) + } + + fn into_parse_result( + self, + strings: &[Arc], + line_mapping: LineMappingTable, + ) -> Result { + let mut scoped_file_manager = ScopedFileIndexManager::new(); + for cached in self.file_indices { + let (cu_name, index) = cached.into_index(strings)?; + scoped_file_manager.add_compilation_unit(cu_name, index); + } + + let index_entries = self + .index_entries + .into_iter() + .map(|entry| entry.into_index_entry(strings)) + .collect::>>()?; + let cu_ranges = self + .cu_ranges + .into_iter() + .map(|range| { + Ok(( + range.start, + range.end, + gimli::DebugInfoOffset(usize::try_from(range.cu_offset)?), + )) + }) + .collect::>>()?; + let compilation_units = self + .compilation_units + .into_iter() + .map(|(name_id, unit)| { + Ok(( + cached_string(strings, name_id, "compilation unit name")?.to_string(), + unit.into_compilation_unit(strings)?, + )) + }) + .collect::>>()?; + + Ok(DwarfParseResult { + lightweight_index: LightweightIndex::from_cached_entries(index_entries, cu_ranges), + line_mapping, + scoped_file_manager, + compilation_units, + stats: self.stats.into_parse_stats(), + }) + } +} + +#[derive(Default)] +struct StringInterner { + strings: Vec, + ids: HashMap, +} + +impl StringInterner { + fn intern(&mut self, value: &str) -> Result { + if let Some(&id) = self.ids.get(value) { + return Ok(id); + } + let id = u32::try_from(self.strings.len()).context("Too many cached strings")?; + let value = value.to_string(); + self.strings.push(value.clone()); + self.ids.insert(value, id); + Ok(id) + } + + fn id(&self, value: &str) -> Result { + cached_string_id(&self.ids, value) + } +} + +fn cached_string_id(ids: &HashMap, value: &str) -> Result { + ids.get(value) + .copied() + .with_context(|| format!("Cached string was not interned: {value:?}")) +} + +fn cached_string<'a>(strings: &'a [Arc], id: u32, field: &str) -> Result<&'a Arc> { + strings + .get(id as usize) + .with_context(|| format!("Cached {field} string ID {id} is out of bounds")) +} + +#[derive(Serialize, Deserialize)] +struct CachedIndexEntry { + name_id: u32, + die_offset: u64, + unit_offset: u64, + tag: u16, + flags: CachedIndexFlags, + language: Option, + representative_addr: Option, + entry_pc: Option, + function_kind: u8, +} + +impl CachedIndexEntry { + fn from_index_entry(entry: &IndexEntry, strings: &StringInterner) -> Result { + Ok(Self { + name_id: strings.id(&entry.name)?, + die_offset: entry.die_offset.0 as u64, + unit_offset: entry.unit_offset.0 as u64, + tag: entry.tag.0, + flags: CachedIndexFlags::from(entry.flags), + language: entry.language.map(|language| language.0), + representative_addr: entry.representative_addr, + entry_pc: entry.entry_pc, + function_kind: match entry.function_kind { + FunctionDieKind::NotFunction => 0, + FunctionDieKind::AbstractSubprogram => 1, + FunctionDieKind::ConcreteSubprogram => 2, + FunctionDieKind::InlineInstance => 3, + }, + }) + } + + fn into_index_entry(self, strings: &[Arc]) -> Result { + let function_kind = match self.function_kind { + 0 => FunctionDieKind::NotFunction, + 1 => FunctionDieKind::AbstractSubprogram, + 2 => FunctionDieKind::ConcreteSubprogram, + 3 => FunctionDieKind::InlineInstance, + value => anyhow::bail!("Invalid cached function kind {value}"), + }; + + Ok(IndexEntry { + name: Arc::clone(cached_string(strings, self.name_id, "index name")?), + die_offset: gimli::UnitOffset(usize::try_from(self.die_offset)?), + unit_offset: gimli::DebugInfoOffset(usize::try_from(self.unit_offset)?), + tag: gimli::DwTag(self.tag), + flags: self.flags.into_index_flags(), + language: self.language.map(gimli::DwLang), + representative_addr: self.representative_addr, + entry_pc: self.entry_pc, + function_kind, + }) + } +} + +#[derive(Serialize, Deserialize)] +struct CachedIndexFlags { + is_static: bool, + is_main: bool, + has_inline_attribute: bool, + is_linkage: bool, + is_type_declaration: bool, + is_synthesized: bool, +} + +impl From for CachedIndexFlags { + fn from(flags: IndexFlags) -> Self { + Self { + is_static: flags.is_static, + is_main: flags.is_main, + has_inline_attribute: flags.has_inline_attribute, + is_linkage: flags.is_linkage, + is_type_declaration: flags.is_type_declaration, + is_synthesized: flags.is_synthesized, + } + } +} + +impl CachedIndexFlags { + fn into_index_flags(self) -> IndexFlags { + IndexFlags { + is_static: self.is_static, + is_main: self.is_main, + has_inline_attribute: self.has_inline_attribute, + is_linkage: self.is_linkage, + is_type_declaration: self.is_type_declaration, + is_synthesized: self.is_synthesized, + } + } +} + +#[derive(Serialize, Deserialize)] +struct CachedCuRange { + start: u64, + end: u64, + cu_offset: u64, +} + +#[derive(Serialize, Deserialize)] +struct CachedScopedFileIndex { + cu_name_id: u32, + comp_dir_id: Option, + directory_ids: Vec, + dwarf_version: u16, + files: Vec, +} + +impl CachedScopedFileIndex { + fn from_index( + cu_name: &str, + index: &LightweightFileIndex, + strings: &StringInterner, + ) -> Result { + Ok(Self { + cu_name_id: strings.id(cu_name)?, + comp_dir_id: index + .cache_comp_dir() + .map(|directory| strings.id(directory)) + .transpose()?, + directory_ids: index + .cache_directories() + .map(|directory| strings.id(directory)) + .collect::>>()?, + dwarf_version: index.cache_dwarf_version(), + files: index + .file_entries() + .iter() + .map(|entry| { + Ok(CachedFileEntry { + file_index: entry.file_index, + directory_index: entry.directory_index, + filename_id: strings.id(&entry.filename)?, + }) + }) + .collect::>>()?, + }) + } + + fn into_index(self, strings: &[Arc]) -> Result<(String, LightweightFileIndex)> { + let comp_dir = self + .comp_dir_id + .map(|id| cached_string(strings, id, "compilation directory")) + .transpose()? + .map(ToString::to_string); + let mut index = LightweightFileIndex::new(comp_dir, self.dwarf_version); + for directory_id in self.directory_ids { + index + .add_directory(cached_string(strings, directory_id, "line directory")?.to_string()); + } + for file in self.files { + index.add_file_entry( + file.file_index, + file.directory_index, + cached_string(strings, file.filename_id, "line filename")?.to_string(), + ); + } + Ok(( + cached_string(strings, self.cu_name_id, "scoped CU name")?.to_string(), + index, + )) + } +} + +#[derive(Serialize, Deserialize)] +struct CachedFileEntry { + file_index: u64, + directory_index: u64, + filename_id: u32, +} + +#[derive(Serialize, Deserialize)] +struct CachedCompilationUnit { + base_directory_id: u32, + include_directory_ids: Vec, + files: Vec, +} + +impl CachedCompilationUnit { + fn from_unit(unit: &CompilationUnit, strings: &StringInterner) -> Result { + Ok(Self { + base_directory_id: strings.id(&unit.base_directory)?, + include_directory_ids: unit + .include_directories + .iter() + .map(|directory| strings.id(directory)) + .collect::>>()?, + files: unit + .files + .iter() + .map(|file| CachedSourceFile::from_file(file, strings)) + .collect::>>()?, + }) + } + + fn into_compilation_unit(self, strings: &[Arc]) -> Result { + Ok(CompilationUnit { + base_directory: cached_string(strings, self.base_directory_id, "CU base directory")? + .to_string(), + include_directories: self + .include_directory_ids + .into_iter() + .map(|id| Ok(cached_string(strings, id, "CU include directory")?.to_string())) + .collect::>>()?, + files: self + .files + .into_iter() + .map(|file| file.into_source_file(strings)) + .collect::>>()?, + }) + } +} + +#[derive(Serialize, Deserialize)] +struct CachedSourceFile { + directory_path_id: u32, + filename_id: u32, + full_path_id: u32, +} + +impl CachedSourceFile { + fn from_file(file: &SourceFile, strings: &StringInterner) -> Result { + Ok(Self { + directory_path_id: strings.id(&file.directory_path)?, + filename_id: strings.id(&file.filename)?, + full_path_id: strings.id(&file.full_path)?, + }) + } + + fn into_source_file(self, strings: &[Arc]) -> Result { + Ok(SourceFile { + directory_path: cached_string(strings, self.directory_path_id, "source directory")? + .to_string(), + filename: cached_string(strings, self.filename_id, "source filename")?.to_string(), + full_path: cached_string(strings, self.full_path_id, "source full path")?.to_string(), + }) + } +} + +#[derive(Serialize, Deserialize)] +struct CachedParseStats { + total_functions: u64, + total_variables: u64, + total_line_entries: u64, + total_files: u64, +} + +impl From<&DwarfParseStats> for CachedParseStats { + fn from(stats: &DwarfParseStats) -> Self { + Self { + total_functions: stats.total_functions as u64, + total_variables: stats.total_variables as u64, + total_line_entries: stats.total_line_entries as u64, + total_files: stats.total_files as u64, + } + } +} + +impl CachedParseStats { + fn into_parse_stats(self) -> DwarfParseStats { + DwarfParseStats { + total_functions: self.total_functions as usize, + total_variables: self.total_variables as usize, + total_line_entries: self.total_line_entries as usize, + total_files: self.total_files as usize, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::LineEntry; + use std::sync::Arc; + + fn sample_parse_result() -> DwarfParseResult { + let entry = IndexEntry { + name: Arc::from("cache_probe"), + die_offset: gimli::UnitOffset(12), + unit_offset: gimli::DebugInfoOffset(4), + tag: gimli::constants::DW_TAG_subprogram, + flags: IndexFlags::default(), + language: Some(gimli::constants::DW_LANG_C11), + representative_addr: Some(0x1234), + entry_pc: None, + function_kind: FunctionDieKind::ConcreteSubprogram, + }; + let lightweight_index = LightweightIndex::from_cached_entries( + vec![entry], + vec![(0x1200, 0x1300, gimli::DebugInfoOffset(4))], + ); + + let mut file_index = LightweightFileIndex::new(Some("/src".to_string()), 4); + file_index.add_file_entry(1, 0, "cache.c".to_string()); + let mut scoped_file_manager = ScopedFileIndexManager::new(); + scoped_file_manager.add_compilation_unit("cache.c".to_string(), file_index); + let line_mapping = LineMappingTable::from_entries_with_scoped_manager( + vec![LineEntry { + address: 0x1234, + end_address: Some(0x1240), + file_path: String::new(), + file_index: 1, + compilation_unit: Arc::from("cache.c"), + line: 17, + column: 3, + is_stmt: true, + prologue_end: true, + }], + &scoped_file_manager, + ); + + DwarfParseResult { + lightweight_index, + line_mapping, + scoped_file_manager, + compilation_units: HashMap::from([( + "cache.c".to_string(), + CompilationUnit { + base_directory: "/src".to_string(), + include_directories: vec!["/src/include".to_string()], + files: vec![SourceFile { + directory_path: "/src".to_string(), + filename: "cache.c".to_string(), + full_path: "/src/cache.c".to_string(), + }], + }, + )]), + stats: DwarfParseStats { + total_functions: 1, + total_variables: 0, + total_line_entries: 1, + total_files: 1, + }, + } + } + + #[test] + fn cache_round_trip_restores_lookup_indices() { + let temp = tempfile::tempdir().unwrap(); + let module_path = temp.path().join("module.bin"); + fs::write(&module_path, b"not-an-elf-cache-fixture").unwrap(); + let mapped = MappedFile::open(&module_path).unwrap(); + let cache = AnalysisCache::new(temp.path().join("cache")); + + let entry_path = cache + .store(&mapped, &mapped, &sample_parse_result()) + .unwrap(); + let restored = cache.load(&mapped, &mapped).unwrap().unwrap(); + + assert!(entry_path.is_file()); + assert!(restored.line_mapping.is_mapped()); + assert_eq!(restored.stats.total_functions, 1); + assert_eq!( + restored + .lightweight_index + .find_dies_by_function_name("cache_probe") + .len(), + 1 + ); + assert_eq!( + restored + .line_mapping + .lookup_addresses_by_path("/src/cache.c", 17), + vec![0x1234] + ); + assert_eq!( + restored + .line_mapping + .lookup_line(0x1235) + .map(|entry| entry.line), + Some(17) + ); + assert!(restored.line_mapping.lookup_line(0x1240).is_none()); + assert_eq!( + restored + .line_mapping + .lookup_all_lines_at_address(0x1234) + .len(), + 1 + ); + assert_eq!( + restored + .scoped_file_manager + .lookup_by_scoped_index("cache.c", 1) + .as_deref(), + Some("/src/cache.c") + ); + } + + #[test] + fn changed_debug_file_does_not_reuse_cache_entry() { + let temp = tempfile::tempdir().unwrap(); + let module_path = temp.path().join("module.bin"); + let debug_path = temp.path().join("module.debug"); + fs::write(&module_path, b"stable-module-fixture").unwrap(); + fs::write(&debug_path, b"first-debug-cache-fixture").unwrap(); + let cache = AnalysisCache::new(temp.path().join("cache")); + let mapped = MappedFile::open(&module_path).unwrap(); + let debug = MappedFile::open(&debug_path).unwrap(); + cache + .store(&mapped, &debug, &sample_parse_result()) + .unwrap(); + drop(debug); + + fs::write(&debug_path, b"second-debug-cache-fixture-with-new-size").unwrap(); + let changed_debug = MappedFile::open(&debug_path).unwrap(); + + assert!(cache.load(&mapped, &changed_debug).unwrap().is_none()); + } + + #[test] + fn forged_metadata_length_returns_error_without_panicking() { + let temp = tempfile::tempdir().unwrap(); + let module_path = temp.path().join("module.bin"); + fs::write(&module_path, b"not-an-elf-cache-fixture").unwrap(); + let mapped = MappedFile::open(&module_path).unwrap(); + let cache = AnalysisCache::new(temp.path().join("cache")); + let entry_path = cache + .store(&mapped, &mapped, &sample_parse_result()) + .unwrap(); + + let mut encoded = fs::read(&entry_path).unwrap(); + let header_len = u32::from_le_bytes(encoded[12..16].try_into().unwrap()) as usize; + let payload_offset = CACHE_FILE_PREFIX_LEN + header_len; + // The metadata begins with a Vec length followed by the first entry's + // String length. Forge the String length to exercise the bounded + // decoder before it can allocate from an untrusted size. + encoded[payload_offset + 8..payload_offset + 16].copy_from_slice(&u64::MAX.to_le_bytes()); + fs::write(&entry_path, encoded).unwrap(); + + let outcome = catch_unwind(AssertUnwindSafe(|| cache.load(&mapped, &mapped))); + assert!(outcome.is_ok(), "malformed cache must not panic"); + let error = match outcome.unwrap() { + Ok(_) => panic!("malformed cache unexpectedly decoded"), + Err(error) => error.to_string(), + }; + assert!( + error.contains("Failed to decode analysis cache metadata"), + "unexpected cache error: {error}" + ); + } + + #[test] + fn oversized_cache_is_rejected_before_decoding() { + let temp = tempfile::tempdir().unwrap(); + let module_path = temp.path().join("module.bin"); + fs::write(&module_path, b"not-an-elf-cache-fixture").unwrap(); + let mapped = MappedFile::open(&module_path).unwrap(); + let cache = AnalysisCache::new(temp.path().join("cache")); + let entry_path = cache + .store(&mapped, &mapped, &sample_parse_result()) + .unwrap(); + OpenOptions::new() + .write(true) + .open(&entry_path) + .unwrap() + .set_len(MAX_CACHE_FILE_BYTES + 1) + .unwrap(); + + let error = match cache.load(&mapped, &mapped) { + Ok(_) => panic!("oversized cache unexpectedly decoded"), + Err(error) => error.to_string(), + }; + assert!( + error.contains("exceeding") && error.contains("byte limit"), + "unexpected cache error: {error}" + ); + } + + #[test] + fn invalid_mapped_line_string_id_does_not_panic() { + let temp = tempfile::tempdir().unwrap(); + let module_path = temp.path().join("module.bin"); + fs::write(&module_path, b"not-an-elf-cache-fixture").unwrap(); + let mapped = MappedFile::open(&module_path).unwrap(); + let cache = AnalysisCache::new(temp.path().join("cache")); + let entry_path = cache + .store(&mapped, &mapped, &sample_parse_result()) + .unwrap(); + + let mut encoded = fs::read(&entry_path).unwrap(); + let header_len = u32::from_le_bytes(encoded[12..16].try_into().unwrap()) as usize; + let header: CacheHeader = cache_codec() + .deserialize(&encoded[CACHE_FILE_PREFIX_LEN..CACHE_FILE_PREFIX_LEN + header_len]) + .unwrap(); + let row_offset = CACHE_FILE_PREFIX_LEN + header_len + header.layout.metadata_len as usize; + encoded[row_offset + 16..row_offset + 20].copy_from_slice(&u32::MAX.to_le_bytes()); + fs::write(&entry_path, encoded).unwrap(); + + let restored = cache.load(&mapped, &mapped).unwrap().unwrap(); + let outcome = catch_unwind(AssertUnwindSafe(|| { + ( + restored.line_mapping.lookup_line(0x1234), + restored + .line_mapping + .lookup_addresses_by_path("/src/cache.c", 17), + ) + })); + assert!(outcome.is_ok(), "malformed mapped rows must not panic"); + let (line, addresses) = outcome.unwrap(); + assert!(line.is_none()); + assert!(addresses.is_empty()); + } + + #[test] + fn loaded_cache_is_stable_after_backing_file_is_truncated() { + let temp = tempfile::tempdir().unwrap(); + let module_path = temp.path().join("module.bin"); + fs::write(&module_path, b"not-an-elf-cache-fixture").unwrap(); + let mapped = MappedFile::open(&module_path).unwrap(); + let cache = AnalysisCache::new(temp.path().join("cache")); + let entry_path = cache + .store(&mapped, &mapped, &sample_parse_result()) + .unwrap(); + let restored = cache.load(&mapped, &mapped).unwrap().unwrap(); + + OpenOptions::new() + .write(true) + .open(entry_path) + .unwrap() + .set_len(0) + .unwrap(); + + assert_eq!( + restored + .line_mapping + .lookup_addresses_by_path("/src/cache.c", 17), + vec![0x1234] + ); + } +} diff --git a/ghostscope-dwarf/src/analyzer/mod.rs b/ghostscope-dwarf/src/analyzer/mod.rs index 8f0e2745..09a1fa1d 100644 --- a/ghostscope-dwarf/src/analyzer/mod.rs +++ b/ghostscope-dwarf/src/analyzer/mod.rs @@ -1,11 +1,12 @@ //! Main DWARF analyzer - unified entry point for all DWARF operations use crate::{ + analysis_cache::AnalysisCache, core::{ mapping::ModuleMapping, CallerFrameRecovery, DebugInfoSource, ModuleAddress, Result, SectionType, SourceLocation, }, - loader::ExplicitDebugFile, + loader::{DwarfLoadOptions, ExplicitDebugFile}, objfile::LoadedObjfile, semantics::{CompactUnwindRow, CompactUnwindTable, PcContext}, }; @@ -40,6 +41,8 @@ pub struct DwarfAnalyzer { modules: HashMap, /// Cached PC semantic contexts for repeated symbol/source lookups. pc_context_cache: RwLock, + /// Persistent target-independent analysis cache used for newly loaded modules. + analysis_cache: Option, } impl DwarfAnalyzer { @@ -452,6 +455,7 @@ impl DwarfAnalyzer { } loader = loader.with_loose_debug_match(allow_loose_debug_match); loader = loader.with_debuginfod_client(debuginfod_client); + loader = loader.with_analysis_cache(self.analysis_cache.clone()); let modules = loader .with_progress_callback(progress_callback) @@ -511,6 +515,32 @@ impl DwarfAnalyzer { explicit_debug_file: Option, progress_callback: F, ) -> Result + where + F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, + { + let options = DwarfLoadOptions { + debug_search_paths: debug_search_paths.to_vec(), + allow_loose_debug_match, + explicit_debug_file, + debuginfod_client, + analysis_cache: None, + }; + Self::from_pid_runtime_modules_with_options_and_progress( + pid, + runtime_modules, + options, + progress_callback, + ) + .await + } + + /// Create a PID analyzer with explicit module-loading options. + pub async fn from_pid_runtime_modules_with_options_and_progress( + pid: u32, + runtime_modules: Vec, + options: DwarfLoadOptions, + progress_callback: F, + ) -> Result where F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, { @@ -541,12 +571,13 @@ impl DwarfAnalyzer { let mut loader = crate::loader::ModuleLoader::new(module_mappings).parallel(); // Configure debug search paths if provided - if !debug_search_paths.is_empty() { - loader = loader.with_debug_search_paths(debug_search_paths.to_vec()); + if !options.debug_search_paths.is_empty() { + loader = loader.with_debug_search_paths(options.debug_search_paths); } - loader = loader.with_loose_debug_match(allow_loose_debug_match); - loader = loader.with_explicit_debug_file(explicit_debug_file); - loader = loader.with_debuginfod_client(debuginfod_client); + loader = loader.with_loose_debug_match(options.allow_loose_debug_match); + loader = loader.with_explicit_debug_file(options.explicit_debug_file); + loader = loader.with_debuginfod_client(options.debuginfod_client); + loader = loader.with_analysis_cache(options.analysis_cache.clone()); let modules = loader .with_progress_callback(progress_callback) @@ -559,7 +590,11 @@ impl DwarfAnalyzer { modules.len() ); - Ok(Self::from_modules(pid, modules)) + Ok(Self::from_modules_with_analysis_cache( + pid, + modules, + options.analysis_cache, + )) } /// Create DWARF analyzer from executable path (single module mode, now async parallel) @@ -653,6 +688,28 @@ impl DwarfAnalyzer { explicit_debug_file: Option, progress_callback: F, ) -> Result + where + P: AsRef, + F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, + { + let exec_path = exec_path.as_ref().to_path_buf(); + let options = DwarfLoadOptions { + debug_search_paths: debug_search_paths.to_vec(), + allow_loose_debug_match, + explicit_debug_file: explicit_debug_file + .map(|debug_file| ExplicitDebugFile::new(exec_path.clone(), debug_file)), + debuginfod_client, + analysis_cache: None, + }; + Self::from_exec_path_with_options_and_progress(exec_path, options, progress_callback).await + } + + /// Create an executable analyzer with explicit module-loading options. + pub async fn from_exec_path_with_options_and_progress( + exec_path: P, + options: DwarfLoadOptions, + progress_callback: F, + ) -> Result where P: AsRef, F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, @@ -667,6 +724,7 @@ impl DwarfAnalyzer { pid: 0, // No specific PID in exec mode modules: HashMap::new(), pc_context_cache: RwLock::new(PcContextCache::default()), + analysis_cache: options.analysis_cache.clone(), }; // Create a single module mapping for the executable @@ -694,10 +752,13 @@ impl DwarfAnalyzer { let start_time = std::time::Instant::now(); match LoadedObjfile::load_parallel( module_mapping, - debug_search_paths, - allow_loose_debug_match, - explicit_debug_file, - debuginfod_client, + &options.debug_search_paths, + options.allow_loose_debug_match, + options + .explicit_debug_file + .map(|explicit| explicit.debug_file), + options.debuginfod_client, + options.analysis_cache, ) .await { @@ -716,6 +777,7 @@ impl DwarfAnalyzer { parse_time_ms, index_time_ms, module_total_time_ms, + analysis_cache_status: module_data.analysis_cache_status().clone(), }, current: 1, total: 1, @@ -745,12 +807,16 @@ impl DwarfAnalyzer { Ok(analyzer) } - /// Create analyzer from pre-loaded modules (for Builder pattern) - pub(crate) fn from_modules(pid: u32, modules: Vec) -> Self { + fn from_modules_with_analysis_cache( + pid: u32, + modules: Vec, + analysis_cache: Option, + ) -> Self { let mut analyzer = Self { pid, modules: HashMap::new(), pc_context_cache: RwLock::new(PcContextCache::default()), + analysis_cache, }; for module in modules { diff --git a/ghostscope-dwarf/src/analyzer/types.rs b/ghostscope-dwarf/src/analyzer/types.rs index 959baf7b..7228da0b 100644 --- a/ghostscope-dwarf/src/analyzer/types.rs +++ b/ghostscope-dwarf/src/analyzer/types.rs @@ -1,6 +1,21 @@ use crate::{core::DebugInfoSource, semantics::VisibleVariable}; use std::path::PathBuf; +/// Outcome of consulting the persistent analysis cache for one module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnalysisCacheStatus { + Disabled, + Hit, + Miss, + Rejected { reason: String }, +} + +impl AnalysisCacheStatus { + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit) + } +} + /// Events emitted during module loading process #[derive(Debug, Clone)] pub enum ModuleLoadingEvent { @@ -43,6 +58,7 @@ pub struct ModuleLoadingStats { pub parse_time_ms: u64, pub index_time_ms: u64, pub module_total_time_ms: u64, + pub analysis_cache_status: AnalysisCacheStatus, } /// Rich query result for a single address within a module. diff --git a/ghostscope-dwarf/src/index/lightweight_file_index.rs b/ghostscope-dwarf/src/index/lightweight_file_index.rs index 0560fe42..54a255f8 100644 --- a/ghostscope-dwarf/src/index/lightweight_file_index.rs +++ b/ghostscope-dwarf/src/index/lightweight_file_index.rs @@ -156,6 +156,18 @@ impl LightweightFileIndex { pub fn file_entries(&self) -> &[LightweightFileEntry] { &self.file_entries } + + pub(crate) fn cache_comp_dir(&self) -> Option<&str> { + self.comp_dir.as_deref() + } + + pub(crate) fn cache_directories(&self) -> impl Iterator { + self.directories.iter().map(AsRef::as_ref) + } + + pub(crate) fn cache_dwarf_version(&self) -> u16 { + self.dwarf_version + } } /// Scoped file index manager that maintains per-CU file indices @@ -237,6 +249,12 @@ impl ScopedFileIndexManager { .get(compilation_unit) .map(|arc| arc.as_ref()) } + + pub(crate) fn cache_file_indices(&self) -> impl Iterator { + self.cu_file_indices + .iter() + .map(|(name, index)| (name.as_ref(), index.as_ref())) + } } impl Default for ScopedFileIndexManager { diff --git a/ghostscope-dwarf/src/index/lightweight_index.rs b/ghostscope-dwarf/src/index/lightweight_index.rs index df93bd72..05a36ef1 100644 --- a/ghostscope-dwarf/src/index/lightweight_index.rs +++ b/ghostscope-dwarf/src/index/lightweight_index.rs @@ -414,6 +414,65 @@ impl LightweightIndex { self.entries.len() } + pub(crate) fn cache_entries(&self) -> &[IndexEntry] { + &self.entries + } + + pub(crate) fn cache_cu_ranges(&self) -> Vec<(u64, u64, DebugInfoOffset)> { + self.cu_range_map + .iter() + .map(|(start, (end, cu))| (*start, *end, *cu)) + .collect() + } + + pub(crate) fn from_cached_entries( + entries: Vec, + cu_ranges: Vec<(u64, u64, DebugInfoOffset)>, + ) -> Self { + let mut shard = LightweightIndexShard::default(); + for entry in entries { + let key = entry.name.to_string(); + match entry.tag { + gimli::constants::DW_TAG_subprogram + | gimli::constants::DW_TAG_inlined_subroutine => { + shard.push_function_entry(key, entry); + } + gimli::constants::DW_TAG_variable => { + shard.push_variable_entry(key, entry); + } + gimli::constants::DW_TAG_structure_type + | gimli::constants::DW_TAG_class_type + | gimli::constants::DW_TAG_union_type + | gimli::constants::DW_TAG_enumeration_type + | gimli::constants::DW_TAG_typedef => { + shard.push_type_entry(key, entry); + } + _ => shard.entries.push(entry), + } + } + + let mut index = Self::from_shards(vec![shard]); + index.cu_range_map = cu_ranges + .into_iter() + .map(|(start, end, cu)| (start, (end, cu))) + .collect(); + + let mut per_cu = HashMap::>::new(); + for (idx, entry) in index.entries.iter().enumerate() { + if Self::is_function_tag(entry.tag) { + if let Some(address) = entry.representative_addr.or(entry.entry_pc) { + per_cu + .entry(entry.unit_offset) + .or_default() + .insert(address, idx); + } + } + } + index.func_addr_by_cu = per_cu; + index.cu_maps_built = true; + index + } + /// Get total statistics pub fn get_stats(&self) -> (usize, usize, usize) { ( diff --git a/ghostscope-dwarf/src/index/line_mapping.rs b/ghostscope-dwarf/src/index/line_mapping.rs index 92a030be..f3024a4d 100644 --- a/ghostscope-dwarf/src/index/line_mapping.rs +++ b/ghostscope-dwarf/src/index/line_mapping.rs @@ -1,7 +1,35 @@ //! Pure address→line mapping lookup (no parsing, no file operations) use crate::{core::LineEntry, path_match}; -use std::collections::{BTreeMap, HashMap, HashSet}; +use anyhow::{Context, Result}; +use memmap2::Mmap; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +pub(crate) const MAPPED_LINE_ROW_SIZE: usize = 49; +pub(crate) const MAPPED_LINE_PATH_INDEX_SIZE: usize = 4; + +pub(crate) fn encode_mapped_line_row( + entry: &LineEntry, + path_id: u32, + compilation_unit_id: u32, +) -> [u8; MAPPED_LINE_ROW_SIZE] { + let mut row = [0_u8; MAPPED_LINE_ROW_SIZE]; + row[0..8].copy_from_slice(&entry.address.to_le_bytes()); + row[8..16].copy_from_slice(&entry.end_address.unwrap_or_default().to_le_bytes()); + row[16..20].copy_from_slice(&path_id.to_le_bytes()); + row[20..28].copy_from_slice(&entry.file_index.to_le_bytes()); + row[28..32].copy_from_slice(&compilation_unit_id.to_le_bytes()); + row[32..40].copy_from_slice(&entry.line.to_le_bytes()); + row[40..48].copy_from_slice(&entry.column.to_le_bytes()); + row[48] = u8::from(entry.is_stmt) + | (u8::from(entry.prologue_end) << 1) + | (u8::from(entry.end_address.is_some()) << 2); + row +} /// Pure line mapping table for fast address→line lookup #[derive(Debug)] @@ -21,6 +49,20 @@ pub struct LineMappingTable { /// Basename to full paths mapping for flexible path matching /// e.g., "nginx.c" → ["/home/user/nginx/src/core/nginx.c", ...] basename_to_paths: HashMap>, + + mapped: Option, +} + +#[derive(Debug)] +struct MappedLineMapping { + data: Arc, + rows_offset: usize, + row_count: usize, + path_index_offset: usize, + path_index_count: usize, + strings: Arc<[Arc]>, + path_ids_by_full_path: HashMap, + path_ids_by_basename: HashMap>, } impl LineMappingTable { @@ -76,7 +118,91 @@ impl LineMappingTable { address_to_line_map, path_line_to_addresses, basename_to_paths, + mapped: None, + } + } + + pub(crate) fn from_mapped_cache( + data: Arc, + rows_offset: usize, + row_count: usize, + path_index_offset: usize, + path_index_count: usize, + strings: Arc<[Arc]>, + line_path_ids: &[u32], + ) -> Result { + checked_section_end( + rows_offset, + row_count, + MAPPED_LINE_ROW_SIZE, + data.len(), + "line rows", + )?; + checked_section_end( + path_index_offset, + path_index_count, + MAPPED_LINE_PATH_INDEX_SIZE, + data.len(), + "line path index", + )?; + + let mut path_ids_by_full_path = HashMap::new(); + let mut path_ids_by_basename: HashMap> = HashMap::new(); + for &path_id in line_path_ids { + let path = strings + .get(path_id as usize) + .with_context(|| format!("Line path string ID {path_id} is out of bounds"))?; + if path.is_empty() { + continue; + } + path_ids_by_full_path.insert(path.to_string(), path_id); + if let Some(basename) = std::path::Path::new(path.as_ref()) + .file_name() + .and_then(|name| name.to_str()) + { + path_ids_by_basename + .entry(basename.to_string()) + .or_default() + .push(path_id); + } + } + for ids in path_ids_by_basename.values_mut() { + ids.sort_unstable(); + ids.dedup(); + } + + Ok(Self { + address_to_line_map: BTreeMap::new(), + path_line_to_addresses: HashMap::new(), + basename_to_paths: HashMap::new(), + mapped: Some(MappedLineMapping { + data, + rows_offset, + row_count, + path_index_offset, + path_index_count, + strings, + path_ids_by_full_path, + path_ids_by_basename, + }), + }) + } + + pub(crate) fn cache_entries(&self) -> Vec> { + if let Some(mapped) = &self.mapped { + return (0..mapped.row_count) + .filter_map(|index| mapped.row(index).map(Cow::Owned)) + .collect(); } + self.address_to_line_map + .values() + .flat_map(|entries| entries.iter().map(Cow::Borrowed)) + .collect() + } + + #[cfg(test)] + pub(crate) fn is_mapped(&self) -> bool { + self.mapped.is_some() } fn representative_entry(entries: &[LineEntry]) -> Option<&LineEntry> { @@ -84,7 +210,10 @@ impl LineMappingTable { } /// Find best matching line (closest address <= target address) - pub(crate) fn lookup_line(&self, address: u64) -> Option<&LineEntry> { + pub(crate) fn lookup_line(&self, address: u64) -> Option> { + if let Some(mapped) = &self.mapped { + return mapped.lookup_line(address).map(Cow::Owned); + } // Use BTreeMap's range to find the largest address <= target address let result = self .address_to_line_map @@ -92,9 +221,10 @@ impl LineMappingTable { .next_back() .and_then(|(_, entries)| { Self::representative_entry(entries).filter(|entry| entry.contains_address(address)) - }); + }) + .map(Cow::Borrowed); - if let Some(entry) = result { + if let Some(entry) = &result { tracing::debug!( "LineMapping::lookup_line: address=0x{:x} -> found entry at 0x{:x}, file='{}', line={}", address, entry.address, entry.file_path, entry.line @@ -110,11 +240,19 @@ impl LineMappingTable { } /// Find all line entries at exact address (for handling overlapping instructions) - pub(crate) fn lookup_all_lines_at_address(&self, address: u64) -> Vec<&LineEntry> { + pub(crate) fn lookup_all_lines_at_address(&self, address: u64) -> Vec> { + if let Some(mapped) = &self.mapped { + return mapped + .rows_at_address(address) + .into_iter() + .map(Cow::Owned) + .collect(); + } if let Some(entries) = self.address_to_line_map.get(&address) { let active_entries: Vec<_> = entries .iter() .filter(|entry| entry.contains_address(address)) + .map(Cow::Borrowed) .collect(); tracing::debug!( "LineMapping::lookup_all_lines_at_address: address=0x{:x} -> {} active entries", @@ -139,6 +277,15 @@ impl LineMappingTable { /// /// For consecutive addresses on the same line, returns only the first is_stmt address pub(crate) fn lookup_addresses_by_path(&self, file_path: &str, line_number: u64) -> Vec { + if let Some(mapped) = &self.mapped { + let Some((addresses, resolved_path)) = mapped.lookup_addresses(file_path, line_number) + else { + tracing::debug!("No addresses found for {}:{}", file_path, line_number); + return Vec::new(); + }; + return self.filter_consecutive_addresses(addresses, &resolved_path, line_number); + } + // Strategy 1: Try exact match first if let Some(addresses) = self .path_line_to_addresses @@ -274,7 +421,8 @@ impl LineMappingTable { if group.len() == 1 { // Single address - check if it's is_stmt let addr = group[0]; - if let Some(entries) = self.address_to_line_map.get(&addr) { + let entries = self.lookup_all_lines_at_address(addr); + if !entries.is_empty() { if entries.iter().any(|entry| { entry.is_stmt && entry.file_path == file_path && entry.line == line_number }) { @@ -296,15 +444,12 @@ impl LineMappingTable { // Multiple consecutive addresses - find the first is_stmt address let mut selected = None; for &addr in &group { - if let Some(entries) = self.address_to_line_map.get(&addr) { - if entries.iter().any(|entry| { - entry.is_stmt - && entry.file_path == file_path - && entry.line == line_number - }) { - selected = Some(addr); - break; - } + let entries = self.lookup_all_lines_at_address(addr); + if entries.iter().any(|entry| { + entry.is_stmt && entry.file_path == file_path && entry.line == line_number + }) { + selected = Some(addr); + break; } } @@ -376,6 +521,10 @@ impl LineMappingTable { function_start ); + if let Some(mapped) = &self.mapped { + return mapped.find_flagged_address(function_start, 1 << 1); + } + let mut best_address: Option = None; // Iterate through addresses starting from function_start @@ -428,6 +577,10 @@ impl LineMappingTable { function_start ); + if let Some(mapped) = &self.mapped { + return mapped.find_flagged_address(function_start.saturating_add(1), 1); + } + // Look for the first is_stmt=true address after function_start for (&address, entries) in self .address_to_line_map @@ -467,11 +620,256 @@ impl LineMappingTable { &self, start_addr: u64, end_addr: u64, - ) -> impl Iterator { + ) -> Vec<(u64, Cow<'_, LineEntry>)> { + if let Some(mapped) = &self.mapped { + return mapped + .rows_in_range(start_addr, end_addr) + .into_iter() + .map(|entry| (entry.address, Cow::Owned(entry))) + .collect(); + } self.address_to_line_map .range(start_addr..=end_addr) - .flat_map(|(address, entries)| entries.iter().map(move |entry| (address, entry))) + .flat_map(|(&address, entries)| { + entries + .iter() + .map(move |entry| (address, Cow::Borrowed(entry))) + }) + .collect() + } +} + +impl MappedLineMapping { + fn row_bytes(&self, index: usize) -> Option<&[u8]> { + let start = self + .rows_offset + .checked_add(index.checked_mul(MAPPED_LINE_ROW_SIZE)?)?; + self.data + .get(start..start.checked_add(MAPPED_LINE_ROW_SIZE)?) + } + + fn row_address(&self, index: usize) -> Option { + read_u64(self.row_bytes(index)?, 0) } + + fn row_path_id(&self, index: usize) -> Option { + read_u32(self.row_bytes(index)?, 16) + } + + fn row_line(&self, index: usize) -> Option { + read_u64(self.row_bytes(index)?, 32) + } + + fn row_flags(&self, index: usize) -> Option { + self.row_bytes(index)?.get(48).copied() + } + + fn row(&self, index: usize) -> Option { + let row = self.row_bytes(index)?; + let path_id = read_u32(row, 16)? as usize; + let compilation_unit_id = read_u32(row, 28)? as usize; + let flags = *row.get(48)?; + let end_address = read_u64(row, 8)?; + Some(LineEntry { + address: read_u64(row, 0)?, + end_address: (flags & 4 != 0).then_some(end_address), + file_path: self.strings.get(path_id)?.to_string(), + file_index: read_u64(row, 20)?, + compilation_unit: Arc::clone(self.strings.get(compilation_unit_id)?), + line: read_u64(row, 32)?, + column: read_u64(row, 40)?, + is_stmt: flags & 1 != 0, + prologue_end: flags & 2 != 0, + }) + } + + fn lower_bound_address(&self, target: u64) -> usize { + let mut left = 0; + let mut right = self.row_count; + while left < right { + let mid = left + (right - left) / 2; + if self.row_address(mid).unwrap_or(u64::MAX) < target { + left = mid + 1; + } else { + right = mid; + } + } + left + } + + fn upper_bound_address(&self, target: u64) -> usize { + let mut left = 0; + let mut right = self.row_count; + while left < right { + let mid = left + (right - left) / 2; + if self + .row_address(mid) + .is_some_and(|address| address <= target) + { + left = mid + 1; + } else { + right = mid; + } + } + left + } + + fn lookup_line(&self, address: u64) -> Option { + let upper = self.upper_bound_address(address); + let entry = self.row(upper.checked_sub(1)?)?; + entry.contains_address(address).then_some(entry) + } + + fn rows_at_address(&self, address: u64) -> Vec { + let start = self.lower_bound_address(address); + let end = self.upper_bound_address(address); + (start..end) + .filter_map(|index| self.row(index)) + .filter(|entry| entry.contains_address(address)) + .collect() + } + + fn rows_in_range(&self, start_address: u64, end_address: u64) -> Vec { + let start = self.lower_bound_address(start_address); + let end = self.upper_bound_address(end_address); + (start..end).filter_map(|index| self.row(index)).collect() + } + + fn path_row_index(&self, index: usize) -> Option { + let start = self + .path_index_offset + .checked_add(index.checked_mul(MAPPED_LINE_PATH_INDEX_SIZE)?)?; + let bytes = self + .data + .get(start..start.checked_add(MAPPED_LINE_PATH_INDEX_SIZE)?)?; + Some(read_u32(bytes, 0)? as usize) + } + + fn path_key(&self, index: usize) -> Option<(u32, u64)> { + let row_index = self.path_row_index(index)?; + Some((self.row_path_id(row_index)?, self.row_line(row_index)?)) + } + + fn lower_bound_path_line(&self, target: (u32, u64)) -> usize { + let mut left = 0; + let mut right = self.path_index_count; + while left < right { + let mid = left + (right - left) / 2; + if self.path_key(mid).unwrap_or((u32::MAX, u64::MAX)) < target { + left = mid + 1; + } else { + right = mid; + } + } + left + } + + fn addresses_for_path_line(&self, path_id: u32, line: u64) -> Vec { + let target = (path_id, line); + let mut index = self.lower_bound_path_line(target); + let mut addresses = Vec::new(); + while index < self.path_index_count && self.path_key(index) == Some(target) { + if let Some(row_index) = self.path_row_index(index) { + if let Some(address) = self.row_address(row_index) { + addresses.push(address); + } + } + index += 1; + } + addresses + } + + fn lookup_addresses(&self, file_path: &str, line: u64) -> Option<(Vec, String)> { + if let Some(&path_id) = self.path_ids_by_full_path.get(file_path) { + let addresses = self.addresses_for_path_line(path_id, line); + if !addresses.is_empty() { + return Some((addresses, file_path.to_string())); + } + } + + let basename = path_match::file_name(file_path); + let path_ids = self.path_ids_by_basename.get(basename)?; + if path_match::has_path_separator(file_path) { + for &path_id in path_ids { + let full_path = self.strings.get(path_id as usize)?.as_ref(); + if path_match::path_component_suffix_matches(full_path, file_path) + || path_match::path_component_suffix_matches(file_path, full_path) + { + let addresses = self.addresses_for_path_line(path_id, line); + if !addresses.is_empty() { + return Some((addresses, full_path.to_string())); + } + } + } + } + + if path_ids.len() == 1 { + let path_id = path_ids[0]; + let full_path = self.strings.get(path_id as usize)?.as_ref(); + let addresses = self.addresses_for_path_line(path_id, line); + if !addresses.is_empty() { + return Some((addresses, full_path.to_string())); + } + } + + for &path_id in path_ids { + let full_path = self.strings.get(path_id as usize)?.as_ref(); + if path_match::path_component_suffix_matches(full_path, file_path) + || path_match::path_component_suffix_matches(file_path, full_path) + { + let addresses = self.addresses_for_path_line(path_id, line); + if !addresses.is_empty() { + return Some((addresses, full_path.to_string())); + } + } + } + None + } + + fn find_flagged_address(&self, start_address: u64, flag_mask: u8) -> Option { + let mut index = self.lower_bound_address(start_address); + while index < self.row_count { + let address = self.row_address(index)?; + let end = self.upper_bound_address(address); + if self.row_flags(end.checked_sub(1)?)? & flag_mask != 0 { + return Some(address); + } + index = end; + } + None + } +} + +fn checked_section_end( + offset: usize, + count: usize, + record_size: usize, + data_len: usize, + section: &str, +) -> Result { + let byte_len = count + .checked_mul(record_size) + .with_context(|| format!("Mapped {section} length overflow"))?; + let end = offset + .checked_add(byte_len) + .with_context(|| format!("Mapped {section} offset overflow"))?; + anyhow::ensure!( + end <= data_len, + "Mapped {section} section ends at {end}, beyond file length {data_len}" + ); + Ok(end) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes( + bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?, + )) +} + +fn read_u64(bytes: &[u8], offset: usize) -> Option { + Some(u64::from_le_bytes( + bytes.get(offset..offset.checked_add(8)?)?.try_into().ok()?, + )) } #[cfg(test)] diff --git a/ghostscope-dwarf/src/index/mod.rs b/ghostscope-dwarf/src/index/mod.rs index 6e63410f..6ebc402c 100644 --- a/ghostscope-dwarf/src/index/mod.rs +++ b/ghostscope-dwarf/src/index/mod.rs @@ -12,6 +12,8 @@ pub(crate) use block_index::{BlockNode, CallSiteParameter, CallSiteRecord}; pub(crate) use cfi_index::CfiIndex; pub(crate) use lightweight_file_index::{LightweightFileIndex, ScopedFileIndexManager}; pub(crate) use lightweight_index::{LightweightIndex, LightweightIndexShard}; -pub(crate) use line_mapping::LineMappingTable; +pub(crate) use line_mapping::{ + encode_mapped_line_row, LineMappingTable, MAPPED_LINE_PATH_INDEX_SIZE, MAPPED_LINE_ROW_SIZE, +}; pub(crate) use path::{directory_from_index, resolve_file_path}; pub(crate) use type_index::TypeNameIndex; diff --git a/ghostscope-dwarf/src/lib.rs b/ghostscope-dwarf/src/lib.rs index 9536c879..6262dd5c 100644 --- a/ghostscope-dwarf/src/lib.rs +++ b/ghostscope-dwarf/src/lib.rs @@ -7,6 +7,7 @@ pub(crate) mod core; // Internal implementation modules +mod analysis_cache; pub(crate) mod binary; pub(crate) mod dwarf_expr; pub(crate) mod index; @@ -20,13 +21,14 @@ pub(crate) mod semantics; pub(crate) mod analyzer; // Re-export main public API only +pub use analysis_cache::AnalysisCache; pub use analyzer::{ - AddressQueryResult, AnalyzerStats, DwarfAnalyzer, ExecutableFileInfo, FunctionQueryResult, - LoadedModuleRuntimeInfo, MainExecutableInfo, ModuleDefaultPolicy, ModuleLoadingEvent, - ModuleLoadingStats, ModuleStats, SectionInfo, SharedLibraryInfo, SimpleFileInfo, - SourceLineAddressSearch, SourceLineQuerySearch, TypeLookupAmbiguity, + AddressQueryResult, AnalysisCacheStatus, AnalyzerStats, DwarfAnalyzer, ExecutableFileInfo, + FunctionQueryResult, LoadedModuleRuntimeInfo, MainExecutableInfo, ModuleDefaultPolicy, + ModuleLoadingEvent, ModuleLoadingStats, ModuleStats, SectionInfo, SharedLibraryInfo, + SimpleFileInfo, SourceLineAddressSearch, SourceLineQuerySearch, TypeLookupAmbiguity, }; -pub use loader::ExplicitDebugFile; +pub use loader::{DwarfLoadOptions, ExplicitDebugFile}; // Re-export essential core and semantic support types. pub use core::{ diff --git a/ghostscope-dwarf/src/loader.rs b/ghostscope-dwarf/src/loader.rs index 0cbf6206..85adbde4 100644 --- a/ghostscope-dwarf/src/loader.rs +++ b/ghostscope-dwarf/src/loader.rs @@ -1,6 +1,7 @@ //! Module loading with Builder pattern and parallel support use crate::{ + analysis_cache::AnalysisCache, analyzer::{ModuleLoadingEvent, ModuleLoadingStats}, core::{mapping::ModuleMapping, Result}, objfile::LoadedObjfile, @@ -32,6 +33,16 @@ impl ExplicitDebugFile { } } +/// Target-independent options used while loading DWARF modules. +#[derive(Debug, Clone, Default)] +pub struct DwarfLoadOptions { + pub debug_search_paths: Vec, + pub allow_loose_debug_match: bool, + pub explicit_debug_file: Option, + pub debuginfod_client: Option>, + pub analysis_cache: Option, +} + /// Configuration for module loading (parallel only) #[derive(Debug, Clone)] pub struct LoadConfig { @@ -45,6 +56,8 @@ pub struct LoadConfig { pub explicit_debug_file: Option, /// Optional debuginfod client for build-id based debug file lookup. pub debuginfod_client: Option>, + /// Optional persistent cache for parsed, target-independent DWARF indices. + pub analysis_cache: Option, } impl Default for LoadConfig { @@ -55,6 +68,7 @@ impl Default for LoadConfig { allow_loose_debug_match: false, explicit_debug_file: None, debuginfod_client: None, + analysis_cache: None, } } } @@ -68,6 +82,7 @@ impl LoadConfig { allow_loose_debug_match: false, explicit_debug_file: None, debuginfod_client: None, + analysis_cache: None, } } } @@ -117,6 +132,12 @@ impl ModuleLoader { self } + /// Use a persistent cache for parsed, target-independent DWARF indices. + pub fn with_analysis_cache(mut self, cache: Option) -> Self { + self.config.analysis_cache = cache; + self + } + /// Load with progress callback - always parallel pub async fn load_with_progress(self, progress_callback: F) -> Result> where @@ -159,6 +180,7 @@ impl ModuleLoader { let allow_loose = self.config.allow_loose_debug_match; let explicit_debug_file = self.config.explicit_debug_file.clone(); let debuginfod_client = self.config.debuginfod_client.clone(); + let analysis_cache = self.config.analysis_cache.clone(); let tasks: Vec<_> = self .mappings @@ -169,6 +191,7 @@ impl ModuleLoader { let progress_callback = progress_callback.clone(); let debug_search_paths = debug_search_paths.clone(); let debuginfod_client = debuginfod_client.clone(); + let analysis_cache = analysis_cache.clone(); let explicit_debug_file_for_module = explicit_debug_file.as_ref().and_then(|explicit| { explicit @@ -196,6 +219,7 @@ impl ModuleLoader { allow_loose, explicit_debug_file_for_module, debuginfod_client, + analysis_cache, ) .await; @@ -217,6 +241,7 @@ impl ModuleLoader { parse_time_ms, index_time_ms, module_total_time_ms, + analysis_cache_status: module.analysis_cache_status().clone(), }; progress_callback(ModuleLoadingEvent::LoadingCompleted { diff --git a/ghostscope-dwarf/src/objfile/function_lookup.rs b/ghostscope-dwarf/src/objfile/function_lookup.rs index 8070f842..ce7a4266 100644 --- a/ghostscope-dwarf/src/objfile/function_lookup.rs +++ b/ghostscope-dwarf/src/objfile/function_lookup.rs @@ -244,12 +244,9 @@ impl LoadedObjfile { tracing::debug!("Inline '{}' has no ranges; entry_pc={epc_dbg}", entry.name); } - let entry_pc_has_line_entry = entry.entry_pc.is_some_and(|pc| { - self.line_mapping - .get_entries_in_range(pc, pc) - .next() - .is_some() - }); + let entry_pc_has_line_entry = entry + .entry_pc + .is_some_and(|pc| !self.line_mapping.get_entries_in_range(pc, pc).is_empty()); if let Some(addr) = Self::selected_inline_address(entry, &ranges, entry_pc_has_line_entry) { diff --git a/ghostscope-dwarf/src/objfile/loaded.rs b/ghostscope-dwarf/src/objfile/loaded.rs index fe98da7e..453fc5d4 100644 --- a/ghostscope-dwarf/src/objfile/loaded.rs +++ b/ghostscope-dwarf/src/objfile/loaded.rs @@ -1,6 +1,7 @@ //! Loaded object file: complete DWARF data for a single binary use crate::{ + analyzer::AnalysisCacheStatus, binary::{DwarfReader, MappedFile}, core::{mapping::ModuleMapping, DebugInfoSource, Result}, index::{ @@ -40,6 +41,7 @@ pub(crate) struct LoadedObjfile { pub(super) load_parse_ms: u64, pub(super) load_index_ms: u64, pub(super) load_total_ms: u64, + pub(super) analysis_cache_status: AnalysisCacheStatus, } impl LoadedObjfile { @@ -109,6 +111,10 @@ impl LoadedObjfile { (self.load_parse_ms, self.load_index_ms, self.load_total_ms) } + pub(crate) fn analysis_cache_status(&self) -> &AnalysisCacheStatus { + &self.analysis_cache_status + } + pub(crate) fn get_cfa_result(&self, pc: u64) -> Result> { self.unwind_info.get_cfa_result(pc) } diff --git a/ghostscope-dwarf/src/objfile/loading.rs b/ghostscope-dwarf/src/objfile/loading.rs index 1dd67280..b4ce8d7e 100644 --- a/ghostscope-dwarf/src/objfile/loading.rs +++ b/ghostscope-dwarf/src/objfile/loading.rs @@ -1,5 +1,7 @@ use super::LoadedObjfile; use crate::{ + analysis_cache::AnalysisCache, + analyzer::AnalysisCacheStatus, binary::{ dwarf_endian_from_object, dwarf_reader_from_arc_with_endian, empty_dwarf_reader_with_endian, load_explicit_debug_file, try_load_debug_file, DwarfData, @@ -22,6 +24,7 @@ impl LoadedObjfile { allow_loose_debug_match: bool, explicit_debug_file: Option, debuginfod_client: Option>, + analysis_cache: Option, ) -> Result { tracing::info!("Parallel loading for: {}", module_mapping.path.display()); Self::load_internal_parallel( @@ -30,6 +33,7 @@ impl LoadedObjfile { allow_loose_debug_match, explicit_debug_file, debuginfod_client, + analysis_cache, ) .await } @@ -41,6 +45,7 @@ impl LoadedObjfile { allow_loose_debug_match: bool, explicit_debug_file: Option, debuginfod_client: Option>, + analysis_cache: Option, ) -> Result { let load_started_at = Instant::now(); tracing::debug!( @@ -156,16 +161,72 @@ impl LoadedObjfile { }; let mapped_file = mapped_file_for_dwarf; + let unwind_info_task = tokio::task::spawn_blocking({ + let binary_for_cfi = Arc::clone(&binary_mapped); + let module_path = module_mapping.path.clone(); + move || ModuleUnwindInfo::from_mapped_file(binary_for_cfi, &module_path) + }); + + let (cached_parse_result, analysis_cache_status) = match analysis_cache.clone() { + Some(cache) => { + let binary_for_cache = Arc::clone(&binary_mapped); + let debug_file_for_cache = Arc::clone(&mapped_file); + match tokio::task::spawn_blocking(move || { + cache.load(&binary_for_cache, &debug_file_for_cache) + }) + .await + { + Ok(Ok(Some(result))) => (Some(result), AnalysisCacheStatus::Hit), + Ok(Ok(None)) => (None, AnalysisCacheStatus::Miss), + Ok(Err(error)) => { + tracing::warn!( + "Ignoring unusable DWARF analysis cache for {}: {}", + module_mapping.path.display(), + error + ); + ( + None, + AnalysisCacheStatus::Rejected { + reason: error.to_string(), + }, + ) + } + Err(error) => { + tracing::warn!( + "Ignoring failed DWARF analysis cache task for {}: {}", + module_mapping.path.display(), + error + ); + ( + None, + AnalysisCacheStatus::Rejected { + reason: format!("Cache loading task failed: {error}"), + }, + ) + } + } + } + None => (None, AnalysisCacheStatus::Disabled), + }; - tracing::debug!( - "Starting parallel DWARF parsing with true debug_line || debug_info parallelism..." - ); + let (parse_result, unwind_info) = if let Some(parse_result) = cached_parse_result { + tracing::info!( + "DWARF analysis cache hit for {}", + module_mapping.path.display() + ); + (parse_result, unwind_info_task.await?) + } else { + tracing::debug!( + "Starting parallel DWARF parsing with true debug_line || debug_info parallelism..." + ); - let (pair_result, unwind_info) = tokio::try_join!( - tokio::task::spawn_blocking({ + let parse_task = tokio::task::spawn_blocking({ let dwarf = Arc::clone(&dwarf); let module_path = module_mapping.path.to_string_lossy().to_string(); - move || -> Result<(crate::parser::LineParseResult, crate::parser::DebugParseResult)> { + move || -> Result<( + crate::parser::LineParseResult, + crate::parser::DebugParseResult, + )> { let (line_res, info_res) = rayon::join( || { let parser = crate::parser::DwarfParser::new(&dwarf); @@ -182,21 +243,25 @@ impl LoadedObjfile { (_, Err(e)) => Err(e), } } - }), - tokio::task::spawn_blocking({ - let binary_for_cfi = Arc::clone(&binary_mapped); - let module_path = module_mapping.path.clone(); - move || ModuleUnwindInfo::from_mapped_file(binary_for_cfi, &module_path) - }) - )?; - - let (line_result, info_result) = pair_result?; - - let parse_result = crate::parser::DwarfParser::combine_parallel_results( - line_result, - info_result, - module_mapping.path.to_string_lossy().to_string(), - ); + }); + let (pair_result, unwind_info) = tokio::try_join!(parse_task, unwind_info_task)?; + + let (line_result, info_result) = pair_result?; + let parse_result = crate::parser::DwarfParser::combine_parallel_results( + line_result, + info_result, + module_mapping.path.to_string_lossy().to_string(), + ); + if let Some(cache) = analysis_cache.as_ref().filter(|cache| cache.is_writable()) { + let path = cache.store(&binary_mapped, &mapped_file, &parse_result)?; + tracing::info!( + "Stored DWARF analysis cache for {} at {}", + module_mapping.path.display(), + path.display() + ); + } + (parse_result, unwind_info) + }; let parse_elapsed_ms = load_started_at.elapsed().as_millis(); if let Some(cfi) = unwind_info.cfi_index() { @@ -275,16 +340,18 @@ impl LoadedObjfile { load_parse_ms: parse_elapsed_ms as u64, load_index_ms: index_elapsed_ms as u64, load_total_ms, + analysis_cache_status: analysis_cache_status.clone(), }; tracing::info!( - "True parallel loading completed for {}: {} functions, {} variables, {} line entries, {} files (state: {}, parse_ms: {}, index_ms: {}, total_ms: {})", + "True parallel loading completed for {}: {} functions, {} variables, {} line entries, {} files (state: {}, cache_status: {:?}, parse_ms: {}, index_ms: {}, total_ms: {})", module.module_mapping.path.display(), stats.total_functions, stats.total_variables, stats.total_line_entries, stats.total_files, state_label, + analysis_cache_status, parse_elapsed_ms, index_elapsed_ms, load_total_ms diff --git a/ghostscope-dwarf/src/objfile/source_location.rs b/ghostscope-dwarf/src/objfile/source_location.rs index bc5d8720..5399c867 100644 --- a/ghostscope-dwarf/src/objfile/source_location.rs +++ b/ghostscope-dwarf/src/objfile/source_location.rs @@ -17,18 +17,24 @@ impl LoadedObjfile { if all_line_entries.is_empty() { if let Some(line_entry) = self.line_mapping.lookup_line(address) { - return self.create_source_location_from_entry(line_entry); + return self.create_source_location_from_entry(&line_entry); } return None; } - let best_entry = if all_line_entries.len() == 1 { - let entry = all_line_entries[0]; - self.find_alternative_source_file(entry).unwrap_or(entry) - } else { - self.select_best_line_entry(&all_line_entries) - }; + if all_line_entries.len() == 1 { + let entry = all_line_entries[0].as_ref(); + if let Some(alternative) = self.find_alternative_source_file(entry) { + return self.create_source_location_from_entry(&alternative); + } + return self.create_source_location_from_entry(entry); + } + let entries = all_line_entries + .iter() + .map(|entry| entry.as_ref()) + .collect::>(); + let best_entry = self.select_best_line_entry(&entries); self.create_source_location_from_entry(best_entry) } @@ -41,7 +47,7 @@ impl LoadedObjfile { let all_line_entries = self.line_mapping.lookup_all_lines_at_address(address); let matching_entries: Vec<_> = all_line_entries .iter() - .copied() + .map(|entry| entry.as_ref()) .filter(|entry| { entry.line == u64::from(line_number) && self.line_entry_matches_requested_source(entry, file_path) @@ -67,10 +73,10 @@ impl LoadedObjfile { path_match::source_path_matches(&candidate_path, requested_file_path) } - fn find_alternative_source_file<'a>( - &'a self, - entry: &'a crate::core::LineEntry, - ) -> Option<&'a crate::core::LineEntry> { + fn find_alternative_source_file( + &self, + entry: &crate::core::LineEntry, + ) -> Option { let current_file_path = self.get_file_path_for_entry(entry)?; let is_header = current_file_path.ends_with(".h") @@ -98,7 +104,7 @@ impl LoadedObjfile { continue; } - if let Some(candidate_file_path) = self.get_file_path_for_entry(candidate_entry) { + if let Some(candidate_file_path) = self.get_file_path_for_entry(&candidate_entry) { let is_candidate_header = candidate_file_path.ends_with(".h") || candidate_file_path.ends_with(".hpp") || candidate_file_path.ends_with(".hxx") @@ -110,7 +116,7 @@ impl LoadedObjfile { "find_alternative_source_file: found alternative source file '{}' at address 0x{:x}", candidate_file_path, addr ); - return Some(candidate_entry); + return Some(candidate_entry.into_owned()); } } } diff --git a/ghostscope-dwarf/src/parser/mod.rs b/ghostscope-dwarf/src/parser/mod.rs index 7db38fbb..032360f0 100644 --- a/ghostscope-dwarf/src/parser/mod.rs +++ b/ghostscope-dwarf/src/parser/mod.rs @@ -12,6 +12,7 @@ pub(crate) use detailed_parser::ParsedVariable; pub(crate) use crate::dwarf_expr::ExpressionEvaluator; pub(crate) use detailed_parser::DetailedParser; pub(crate) use fast_parser::{ - CompilationUnit, DebugParseResult, DwarfParseResult, DwarfParser, LineParseResult, SourceFile, + CompilationUnit, DebugParseResult, DwarfParseResult, DwarfParseStats, DwarfParser, + LineParseResult, SourceFile, }; pub(crate) use range_extractor::RangeExtractor; diff --git a/ghostscope-ui/src/components/app/runtime_status.rs b/ghostscope-ui/src/components/app/runtime_status.rs index 92f6199d..bea31ada 100644 --- a/ghostscope-ui/src/components/app/runtime_status.rs +++ b/ghostscope-ui/src/components/app/runtime_status.rs @@ -30,6 +30,7 @@ impl App { types, debug_source: "unknown".to_string(), debug_source_path: None, + analysis_cache_status: crate::events::AnalysisCacheStatus::Disabled, }; self.state .loading_ui @@ -218,6 +219,7 @@ impl App { types: stats.types, debug_source: stats.debug_source.clone(), debug_source_path: stats.debug_source_path.clone(), + analysis_cache_status: stats.analysis_cache_status.clone(), }; self.state .loading_ui diff --git a/ghostscope-ui/src/components/loading/progress.rs b/ghostscope-ui/src/components/loading/progress.rs index c422460b..c091d113 100644 --- a/ghostscope-ui/src/components/loading/progress.rs +++ b/ghostscope-ui/src/components/loading/progress.rs @@ -6,6 +6,7 @@ use ratatui::{ }; use super::{DebugSourceCounts, LoadingProgress, ModuleState}; +use crate::events::AnalysisCacheStatus; /// Progress bar component for loading pub struct ProgressRenderer; @@ -60,6 +61,7 @@ impl ProgressRenderer { Span::styled(" debug: ", Style::default().fg(Color::Gray)), ]; push_module_debug_source_spans(&mut spans, stats); + push_module_cache_status_spans(&mut spans, &stats.analysis_cache_status); spans.push(Span::styled( format!( " | Functions: {} | Variables: {} | Types: {} | Time: {:.1}s", @@ -164,6 +166,30 @@ impl ProgressRenderer { )); push_debug_source_count_spans(&mut spans, &progress.debug_sources); } + if progress.analysis_cache.has_activity() { + spans.push(Span::styled( + " | Cache: ", + Style::default().fg(Color::DarkGray), + )); + push_cache_count( + &mut spans, + "hit", + progress.analysis_cache.hits, + Color::Green, + ); + push_cache_count( + &mut spans, + "miss", + progress.analysis_cache.misses, + Color::Yellow, + ); + push_cache_count( + &mut spans, + "rejected", + progress.analysis_cache.rejected, + Color::Yellow, + ); + } let stats_line = Line::from(spans); @@ -172,6 +198,36 @@ impl ProgressRenderer { } } +fn push_module_cache_status_spans(spans: &mut Vec>, status: &AnalysisCacheStatus) { + let (label, color) = match status { + AnalysisCacheStatus::Disabled => return, + AnalysisCacheStatus::Hit => ("hit", Color::Green), + AnalysisCacheStatus::Miss => ("miss", Color::Yellow), + AnalysisCacheStatus::Rejected { .. } => ("rejected", Color::Yellow), + }; + spans.push(Span::styled( + " | cache: ", + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled(label, Style::default().fg(color))); +} + +fn push_cache_count(spans: &mut Vec>, label: &str, count: usize, color: Color) { + if count == 0 { + return; + } + if spans + .last() + .is_some_and(|span| span.content.as_ref() != " | Cache: ") + { + spans.push(Span::styled(" ", Style::default().fg(Color::DarkGray))); + } + spans.push(Span::styled( + format!("{label}:{count}"), + Style::default().fg(color), + )); +} + fn push_module_debug_source_spans( spans: &mut Vec>, stats: &crate::components::loading::ModuleStats, diff --git a/ghostscope-ui/src/components/loading/state.rs b/ghostscope-ui/src/components/loading/state.rs index cd1013a1..86d30fd9 100644 --- a/ghostscope-ui/src/components/loading/state.rs +++ b/ghostscope-ui/src/components/loading/state.rs @@ -1,3 +1,4 @@ +use crate::events::AnalysisCacheStatus; use std::time::Instant; /// Loading states for different initialization phases @@ -75,6 +76,29 @@ pub struct ModuleStats { pub types: usize, pub debug_source: String, pub debug_source_path: Option, + pub analysis_cache_status: AnalysisCacheStatus, +} + +#[derive(Debug, Clone, Default)] +pub struct AnalysisCacheCounts { + pub hits: usize, + pub misses: usize, + pub rejected: usize, +} + +impl AnalysisCacheCounts { + fn record(&mut self, status: &AnalysisCacheStatus) { + match status { + AnalysisCacheStatus::Disabled => {} + AnalysisCacheStatus::Hit => self.hits += 1, + AnalysisCacheStatus::Miss => self.misses += 1, + AnalysisCacheStatus::Rejected { .. } => self.rejected += 1, + } + } + + pub fn has_activity(&self) -> bool { + self.hits + self.misses + self.rejected > 0 + } } #[derive(Debug, Clone, Default)] @@ -163,6 +187,7 @@ pub struct LoadingProgress { pub failed_count: usize, pub current_loading: Option, pub debug_sources: DebugSourceCounts, + pub analysis_cache: AnalysisCacheCounts, } impl LoadingProgress { @@ -174,6 +199,7 @@ impl LoadingProgress { failed_count: 0, current_loading: None, debug_sources: DebugSourceCounts::default(), + analysis_cache: AnalysisCacheCounts::default(), } } @@ -191,6 +217,7 @@ impl LoadingProgress { pub fn complete_module(&mut self, path: &str, stats: ModuleStats) { if let Some(module) = self.modules.iter_mut().find(|m| m.path == path) { self.debug_sources.record(&stats.debug_source); + self.analysis_cache.record(&stats.analysis_cache_status); module.complete(stats); self.completed_count += 1; if self.current_loading.as_deref() == Some(path) { @@ -243,6 +270,20 @@ impl LoadingProgress { .collect() } + pub fn rejected_cache_modules(&self) -> Vec<&ModuleLoadStatus> { + self.modules + .iter() + .filter(|module| { + module.stats.as_ref().is_some_and(|stats| { + matches!( + &stats.analysis_cache_status, + AnalysisCacheStatus::Rejected { .. } + ) + }) + }) + .collect() + } + pub fn recently_finished(&self, limit: usize) -> Vec<&ModuleLoadStatus> { self.modules .iter() @@ -259,6 +300,7 @@ impl LoadingProgress { types: 0, debug_source: "summary".to_string(), debug_source_path: None, + analysis_cache_status: AnalysisCacheStatus::Disabled, }; for module in &self.modules { diff --git a/ghostscope-ui/src/components/loading/ui.rs b/ghostscope-ui/src/components/loading/ui.rs index 10d6b372..a0714b43 100644 --- a/ghostscope-ui/src/components/loading/ui.rs +++ b/ghostscope-ui/src/components/loading/ui.rs @@ -11,9 +11,11 @@ use super::{ debug_source_style, push_debug_source_count_spans, LoadingProgress, LoadingState, ModuleLoadStatus, ModuleState, ProgressRenderer, }; +use crate::events::AnalysisCacheStatus; const MAX_WELCOME_DEBUG_SOURCE_DETAILS: usize = 8; const MAX_WELCOME_MISSING_EXAMPLES: usize = 3; +const MAX_WELCOME_CACHE_REJECTIONS: usize = 5; /// Enhanced Loading UI component with detailed progress tracking #[derive(Clone, Debug)] @@ -261,6 +263,8 @@ impl LoadingUI { append_debug_source_details(&mut lines, &self.progress); } + append_analysis_cache_details(&mut lines, &self.progress); + // Empty line lines.push(Line::from("")); @@ -418,6 +422,96 @@ impl LoadingUI { } } +fn append_analysis_cache_details(lines: &mut Vec>, progress: &LoadingProgress) { + if !progress.analysis_cache.has_activity() { + return; + } + + let mut summary = vec![Span::styled( + "• Analysis cache: ", + Style::default().fg(Color::White), + )]; + push_cache_summary_count( + &mut summary, + "hit", + "hits", + progress.analysis_cache.hits, + Color::Green, + ); + push_cache_summary_count( + &mut summary, + "miss", + "misses", + progress.analysis_cache.misses, + Color::Yellow, + ); + push_cache_summary_count( + &mut summary, + "rejected", + "rejected", + progress.analysis_cache.rejected, + Color::Yellow, + ); + lines.push(Line::from(summary)); + + let rejected = progress.rejected_cache_modules(); + if rejected.is_empty() { + return; + } + lines.push(Line::from(Span::styled( + "• Cache fallback:", + Style::default().fg(Color::Yellow), + ))); + for module in rejected.iter().take(MAX_WELCOME_CACHE_REJECTIONS) { + let Some(stats) = module.stats.as_ref() else { + continue; + }; + let AnalysisCacheStatus::Rejected { reason } = &stats.analysis_cache_status else { + continue; + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + shorten_middle(&module_file_name(&module.path), 34), + Style::default().fg(Color::White), + ), + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled( + truncate_end(reason, 100), + Style::default().fg(Color::Yellow), + ), + ])); + } + if rejected.len() > MAX_WELCOME_CACHE_REJECTIONS { + lines.push(Line::from(Span::styled( + format!( + " ... {} more rejected cache entries omitted", + rejected.len() - MAX_WELCOME_CACHE_REJECTIONS + ), + Style::default().fg(Color::DarkGray), + ))); + } +} + +fn push_cache_summary_count( + spans: &mut Vec>, + singular: &str, + plural: &str, + count: usize, + color: Color, +) { + if count == 0 { + return; + } + if spans.len() > 1 { + spans.push(Span::styled(", ", Style::default().fg(Color::DarkGray))); + } + spans.push(Span::styled( + format!("{count} {}", if count == 1 { singular } else { plural }), + Style::default().fg(color), + )); +} + fn append_debug_source_details(lines: &mut Vec>, progress: &LoadingProgress) { let debug_source_modules: Vec<&ModuleLoadStatus> = progress .modules @@ -555,6 +649,18 @@ fn shorten_middle(value: &str, max_width: usize) -> String { format!("{prefix}...{suffix}") } +fn truncate_end(value: &str, max_width: usize) -> String { + let width = value.chars().count(); + if width <= max_width { + return value.to_string(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + let prefix: String = value.chars().take(max_width - 3).collect(); + format!("{prefix}...") +} + impl Default for LoadingUI { fn default() -> Self { Self::new() @@ -583,6 +689,7 @@ mod tests { debug_source_path: Some( "/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING".to_string(), ), + analysis_cache_status: AnalysisCacheStatus::Hit, }, ); @@ -597,6 +704,9 @@ mod tests { types: 0, debug_source: "missing".to_string(), debug_source_path: None, + analysis_cache_status: AnalysisCacheStatus::Rejected { + reason: "Failed to decode analysis cache payload".to_string(), + }, }, ); @@ -608,6 +718,10 @@ mod tests { assert!(text.contains("embedded")); assert!(text.contains("libluajit-5.1.so.2.1.ROLLING /usr/local/openresty/luajit/lib/")); assert!(text.contains("Missing DWARF: 1 module (libcrypt.so.1.1.0)")); + assert!(text.contains("Analysis cache: 1 hit, 1 rejected")); + assert!(text.contains("Cache fallback:")); + assert!(text.contains("libcrypt.so.1.1.0")); + assert!(text.contains("Failed to decode analysis cache payload")); } fn plain_text(lines: &[Line<'static>]) -> String { diff --git a/ghostscope-ui/src/events.rs b/ghostscope-ui/src/events.rs index 220c6baf..a5013b86 100644 --- a/ghostscope-ui/src/events.rs +++ b/ghostscope-ui/src/events.rs @@ -11,9 +11,9 @@ pub use debug_info::{ SourceFileGroup, SourceFileInfo, TargetDebugInfo, TargetType, VariableDebugInfo, }; pub use runtime::{ - ExecutionStatus, LoadStatus, ModuleLoadingStats, RuntimeCommand, RuntimeStatus, - ScriptCompilationDetails, ScriptExecutionResult, TraceDefinition, TraceDetailInfo, - TraceLoadDetail, TraceStatus, TraceSummaryInfo, + AnalysisCacheStatus, ExecutionStatus, LoadStatus, ModuleLoadingStats, RuntimeCommand, + RuntimeStatus, ScriptCompilationDetails, ScriptExecutionResult, TraceDefinition, + TraceDetailInfo, TraceLoadDetail, TraceStatus, TraceSummaryInfo, }; pub use source_path::{PathSubstitution, SourcePathInfo}; pub use trace_display::{ diff --git a/ghostscope-ui/src/events/runtime.rs b/ghostscope-ui/src/events/runtime.rs index 6b44054e..fcb05b31 100644 --- a/ghostscope-ui/src/events/runtime.rs +++ b/ghostscope-ui/src/events/runtime.rs @@ -368,6 +368,15 @@ pub struct ModuleLoadingStats { pub debug_source: String, pub debug_source_path: Option, pub load_time_ms: u64, + pub analysis_cache_status: AnalysisCacheStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnalysisCacheStatus { + Disabled, + Hit, + Miss, + Rejected { reason: String }, } /// Summary information for all traces diff --git a/ghostscope/src/cli/loading_reporter.rs b/ghostscope/src/cli/loading_reporter.rs index 3a8aaeb5..b81f3150 100644 --- a/ghostscope/src/cli/loading_reporter.rs +++ b/ghostscope/src/cli/loading_reporter.rs @@ -1,4 +1,4 @@ -use ghostscope_dwarf::{DebugInfoSource, ModuleLoadingEvent}; +use ghostscope_dwarf::{AnalysisCacheStatus, DebugInfoSource, ModuleLoadingEvent}; use std::io::{self, IsTerminal, Write}; use std::path::Path; use std::time::{Duration, Instant}; @@ -146,6 +146,7 @@ struct LoadingProgress { functions: usize, variables: usize, types: usize, + analysis_cache: AnalysisCacheCounts, } impl LoadingProgress { @@ -162,6 +163,7 @@ impl LoadingProgress { functions: 0, variables: 0, types: 0, + analysis_cache: AnalysisCacheCounts::default(), } } @@ -193,6 +195,7 @@ impl LoadingProgress { self.functions += stats.functions; self.variables += stats.variables; self.types += stats.types; + self.analysis_cache.record(&stats.analysis_cache_status); self.module_reports.push(ModuleLoadReport { path: module_path.clone(), source: stats.debug_info_source, @@ -200,6 +203,7 @@ impl LoadingProgress { variables: stats.variables, types: stats.types, load_time_ms: stats.load_time_ms, + analysis_cache_status: stats.analysis_cache_status, }); if self.current_module.as_deref() == Some(module_path.as_str()) { self.current_module = None; @@ -302,6 +306,13 @@ impl LoadingProgress { )); } + if self.analysis_cache.has_activity() { + lines.push(format!( + " analysis cache: {}", + self.analysis_cache.summary_colored(colors) + )); + } + let debuggable_modules = self.debuggable_module_reports(); if !self.module_reports.is_empty() || !self.module_failures.is_empty() { lines.push(format!( @@ -338,6 +349,29 @@ impl LoadingProgress { )); } + let rejected_cache_modules = self.rejected_cache_modules(); + if !rejected_cache_modules.is_empty() { + lines.push(format!(" {}", colors.yellow("cache fallback:"))); + for module in rejected_cache_modules.iter().take(MAX_MODULE_REPORT_LINES) { + let AnalysisCacheStatus::Rejected { reason } = &module.analysis_cache_status else { + continue; + }; + lines.push(format!( + " {} {:<64} {}", + colors.yellow("rejected"), + shorten_path(&module.path, 64), + truncate_text(reason, 120) + )); + } + if rejected_cache_modules.len() > MAX_MODULE_REPORT_LINES { + let omitted = rejected_cache_modules.len() - MAX_MODULE_REPORT_LINES; + lines.push(format!( + " ... {omitted} more rejected cache {} omitted", + if omitted == 1 { "entry" } else { "entries" } + )); + } + } + if !self.module_failures.is_empty() { lines.push(" module failures:".to_string()); for failure in self.module_failures.iter().take(MAX_MODULE_REPORT_LINES) { @@ -364,6 +398,18 @@ impl LoadingProgress { .collect() } + fn rejected_cache_modules(&self) -> Vec<&ModuleLoadReport> { + self.module_reports + .iter() + .filter(|module| { + matches!( + &module.analysis_cache_status, + AnalysisCacheStatus::Rejected { .. } + ) + }) + .collect() + } + fn missing_module_hint(&self) -> String { let missing_modules: Vec<_> = self .module_reports @@ -441,6 +487,7 @@ struct ModuleLoadReport { variables: usize, types: usize, load_time_ms: u64, + analysis_cache_status: AnalysisCacheStatus, } #[derive(Debug)] @@ -449,6 +496,58 @@ struct ModuleFailureReport { error: String, } +#[derive(Debug, Default)] +struct AnalysisCacheCounts { + hits: usize, + misses: usize, + rejected: usize, +} + +impl AnalysisCacheCounts { + fn record(&mut self, status: &AnalysisCacheStatus) { + match status { + AnalysisCacheStatus::Disabled => {} + AnalysisCacheStatus::Hit => self.hits += 1, + AnalysisCacheStatus::Miss => self.misses += 1, + AnalysisCacheStatus::Rejected { .. } => self.rejected += 1, + } + } + + fn has_activity(&self) -> bool { + self.hits + self.misses + self.rejected > 0 + } + + fn summary_colored(&self, colors: &crate::cli::color::CliColors) -> String { + let mut parts = Vec::new(); + if self.hits > 0 { + parts.push(colors.green(format_count(self.hits, "hit", "hits"))); + } + if self.misses > 0 { + parts.push(colors.yellow(format_count(self.misses, "miss", "misses"))); + } + if self.rejected > 0 { + parts.push(colors.yellow(format_count(self.rejected, "rejected", "rejected"))); + } + parts.join(", ") + } +} + +fn format_count(count: usize, singular: &str, plural: &str) -> String { + format!("{count} {}", if count == 1 { singular } else { plural }) +} + +fn truncate_text(value: &str, max_width: usize) -> String { + let width = value.chars().count(); + if width <= max_width { + return value.to_string(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + let prefix: String = value.chars().take(max_width - 3).collect(); + format!("{prefix}...") +} + #[derive(Debug, Default)] struct DebugSourceCounts { embedded: usize, @@ -634,7 +733,9 @@ mod tests { CliLoadingReporter, DebugSourceCounts, }; use crate::config::CliColorMode; - use ghostscope_dwarf::{DebugInfoSource, ModuleLoadingEvent, ModuleLoadingStats}; + use ghostscope_dwarf::{ + AnalysisCacheStatus, DebugInfoSource, ModuleLoadingEvent, ModuleLoadingStats, + }; use std::time::Duration; #[test] @@ -690,6 +791,7 @@ mod tests { parse_time_ms: 30, index_time_ms: 8, module_total_time_ms: 40, + analysis_cache_status: AnalysisCacheStatus::Hit, }, current: 1, total: 2, @@ -714,6 +816,54 @@ mod tests { assert!(report.contains("module details:")); assert!(report.contains("embedded")); assert!(report.contains("/usr/bin/app")); + assert!(report.contains("analysis cache: 1 hit")); + } + + #[test] + fn reporter_summarizes_cache_misses_and_rejections() { + let mut reporter = CliLoadingReporter::new_with_enabled( + false, + crate::cli::color::CliColors::new(false), + Duration::ZERO, + ); + for (path, status) in [ + ("/usr/bin/app", AnalysisCacheStatus::Miss), + ( + "/usr/lib/libbadcache.so", + AnalysisCacheStatus::Rejected { + reason: "Failed to decode analysis cache payload".to_string(), + }, + ), + ] { + reporter.handle_event(ModuleLoadingEvent::LoadingCompleted { + module_path: path.to_string(), + stats: ModuleLoadingStats { + functions: 1, + variables: 0, + types: 0, + debug_info_source: DebugInfoSource::Embedded { + path: path.to_string(), + }, + load_time_ms: 5, + parse_time_ms: 4, + index_time_ms: 1, + module_total_time_ms: 5, + analysis_cache_status: status, + }, + current: 1, + total: 2, + }); + } + + let report = reporter + .progress + .success_report_lines(&reporter.colors, Some("pid=123")) + .join("\n"); + + assert!(report.contains("analysis cache: 1 miss, 1 rejected")); + assert!(report.contains("cache fallback:")); + assert!(report.contains("libbadcache.so")); + assert!(report.contains("Failed to decode analysis cache payload")); } #[test] @@ -734,6 +884,7 @@ mod tests { parse_time_ms: 0, index_time_ms: 0, module_total_time_ms: 5, + analysis_cache_status: AnalysisCacheStatus::Miss, }, current: 1, total: 1, diff --git a/ghostscope/src/cli/mod.rs b/ghostscope/src/cli/mod.rs index eb549989..521b8bb2 100644 --- a/ghostscope/src/cli/mod.rs +++ b/ghostscope/src/cli/mod.rs @@ -4,6 +4,7 @@ mod color; mod docs; mod dry_run; mod loading_reporter; +mod prepare; pub mod script_output; pub mod script_runtime; @@ -14,6 +15,8 @@ use ghostscope_process::pinned_bpf_maps::{ }; use serde::Serialize; +pub use prepare::prepare_analysis_cache; + #[derive(Serialize)] struct JsonPruneReport { root: String, diff --git a/ghostscope/src/cli/prepare.rs b/ghostscope/src/cli/prepare.rs new file mode 100644 index 00000000..bb4a4c53 --- /dev/null +++ b/ghostscope/src/cli/prepare.rs @@ -0,0 +1,60 @@ +use crate::{config::UserConfig, core::session::build_debuginfod_client}; +use anyhow::{Context, Result}; +use ghostscope_dwarf::{AnalysisCache, DwarfAnalyzer, DwarfLoadOptions, ExplicitDebugFile}; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +pub async fn prepare_analysis_cache(config: &UserConfig) -> Result<()> { + config.validate_prepare()?; + + let target = PathBuf::from( + config + .target_path + .as_deref() + .context("--prepare requires --target ")?, + ); + let cache_dir = config + .dwarf_analysis_cache_dir + .clone() + .context("Analysis cache is disabled")?; + let options = DwarfLoadOptions { + debug_search_paths: config.dwarf_search_paths.clone(), + allow_loose_debug_match: config.dwarf_allow_loose_debug_match, + explicit_debug_file: config + .debug_file + .clone() + .map(|debug_file| ExplicitDebugFile::new(target.clone(), debug_file)), + debuginfod_client: build_debuginfod_client(&config.dwarf_debuginfod)?, + analysis_cache: Some(AnalysisCache::new(&cache_dir)), + }; + + let cache_hit = Arc::new(AtomicBool::new(false)); + let cache_hit_for_progress = Arc::clone(&cache_hit); + let analyzer = + DwarfAnalyzer::from_exec_path_with_options_and_progress(&target, options, move |event| { + if let ghostscope_dwarf::ModuleLoadingEvent::LoadingCompleted { stats, .. } = event { + cache_hit_for_progress + .store(stats.analysis_cache_status.is_hit(), Ordering::Relaxed); + } + }) + .await?; + let stats = analyzer.get_module_stats(); + let action = if cache_hit.load(Ordering::Relaxed) { + "Reused" + } else { + "Prepared" + }; + println!( + "{} analysis cache for {} ({} symbols) at {}", + action, + target.display(), + stats.total_symbols, + cache_dir.display() + ); + Ok(()) +} diff --git a/ghostscope/src/cli/script_runtime.rs b/ghostscope/src/cli/script_runtime.rs index 03818e74..9909ea69 100644 --- a/ghostscope/src/cli/script_runtime.rs +++ b/ghostscope/src/cli/script_runtime.rs @@ -708,6 +708,7 @@ mod tests { tui_mode: false, dry_run: false, dry_run_details: false, + prepare: false, should_save_llvm_ir: false, should_save_ebpf: false, should_save_ast: false, @@ -722,6 +723,7 @@ mod tests { dwarf_search_paths: Vec::new(), dwarf_allow_loose_debug_match: false, dwarf_debuginfod: Default::default(), + dwarf_analysis_cache_dir: None, ebpf_config: EbpfConfig::default(), source: Default::default(), config_file_path: None, diff --git a/ghostscope/src/config/args.rs b/ghostscope/src/config/args.rs index 5b27acdf..b716d0c4 100644 --- a/ghostscope/src/config/args.rs +++ b/ghostscope/src/config/args.rs @@ -246,6 +246,18 @@ pub struct Args { #[arg(long, action = clap::ArgAction::SetTrue)] pub dry_run: bool, + /// Parse target debug information into the persistent analysis cache and exit + #[arg(long, action = clap::ArgAction::SetTrue)] + pub prepare: bool, + + /// Directory for persistent parsed DWARF analysis data + #[arg(long, value_name = "DIR", conflicts_with = "no_analysis_cache")] + pub analysis_cache_dir: Option, + + /// Disable persistent parsed DWARF analysis caching + #[arg(long, action = clap::ArgAction::SetTrue)] + pub no_analysis_cache: bool, + /// Include detailed target and variable diagnostics in dry-run output #[arg(long = "dry-run-details", requires = "dry_run", action = clap::ArgAction::SetTrue)] pub dry_run_details: bool, @@ -347,6 +359,9 @@ pub struct ParsedArgs { pub tui_mode: bool, pub dry_run: bool, pub dry_run_details: bool, + pub prepare: bool, + pub analysis_cache_dir: Option, + pub no_analysis_cache: bool, pub script_output: Option, pub status_enabled: bool, pub has_explicit_status_flag: bool, @@ -529,6 +544,9 @@ impl Args { tui_mode, dry_run, dry_run_details, + prepare: parsed.prepare, + analysis_cache_dir: parsed.analysis_cache_dir, + no_analysis_cache: parsed.no_analysis_cache, script_output: parsed.script_output, status_enabled, has_explicit_status_flag, @@ -590,7 +608,7 @@ impl Args { /// Determine whether to start in TUI mode fn determine_tui_mode(parsed: &Args) -> bool { - if parsed.dry_run { + if parsed.dry_run || parsed.prepare { return false; } @@ -660,7 +678,8 @@ impl Args { parsed: &Args, ) -> (bool, bool, crate::config::settings::LogLevel, bool, bool) { // Check if we're in script mode (script provided via --script or --script-file) - let is_script_mode = parsed.script.is_some() || parsed.script_file.is_some(); + let is_script_mode = + parsed.prepare || parsed.script.is_some() || parsed.script_file.is_some(); // Check if explicit log flags were provided let has_explicit_log_flag = parsed.log || parsed.no_log; @@ -776,6 +795,31 @@ mod tests { assert!(help.contains("bpffs")); assert!(help.contains("--script-help")); assert!(help.contains("--args")); + assert!(help.contains("--prepare")); + } + + #[test] + fn parses_prepare_analysis_cache_flags() { + let parsed = Args::parse_args_from(vec![ + "ghostscope".to_string(), + "--prepare".to_string(), + "--target".to_string(), + "/tmp/app".to_string(), + "--analysis-cache-dir".to_string(), + "/tmp/ghostscope-cache".to_string(), + ]); + + match parsed { + ParsedCommand::Trace(args) => { + assert!(args.prepare); + assert!(!args.tui_mode); + assert_eq!( + args.analysis_cache_dir, + Some(PathBuf::from("/tmp/ghostscope-cache")) + ); + } + other => panic!("unexpected parse result: {other:?}"), + } } #[test] diff --git a/ghostscope/src/config/mod.rs b/ghostscope/src/config/mod.rs index bf310fbe..56dd30e7 100644 --- a/ghostscope/src/config/mod.rs +++ b/ghostscope/src/config/mod.rs @@ -19,4 +19,4 @@ pub use args::{ pub use ghostscope_process::PidViews; pub use runtime::ResolvedConfig; pub use settings::{CliColorMode, Config, LogLevel, PanelType}; -pub use user::UserConfig; +pub use user::{ResolvedDebuginfodConfig, UserConfig}; diff --git a/ghostscope/src/config/settings.rs b/ghostscope/src/config/settings.rs index 1f449cec..eb4d66a3 100644 --- a/ghostscope/src/config/settings.rs +++ b/ghostscope/src/config/settings.rs @@ -150,6 +150,19 @@ pub struct DwarfConfig { /// debuginfod client configuration. #[serde(default)] pub debuginfod: DwarfDebuginfodConfig, + /// Persistent cache for parsed, target-independent DWARF indices. + #[serde(default)] + pub analysis_cache: DwarfAnalysisCacheConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DwarfAnalysisCacheConfig { + /// Enable transparent reads from caches populated by prepare mode. + #[serde(default = "default_analysis_cache_enabled")] + pub enabled: bool, + /// Cache directory. Empty/absent uses the platform user cache directory. + #[serde(default)] + pub directory: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, clap::ValueEnum, Default)] @@ -356,6 +369,10 @@ fn default_debug_search_paths() -> Vec { ] } +fn default_analysis_cache_enabled() -> bool { + true +} + fn default_ringbuf_size() -> u64 { 262144 // 256KB } @@ -466,6 +483,16 @@ impl Default for DwarfConfig { search_paths: default_debug_search_paths(), allow_loose_debug_match: false, debuginfod: DwarfDebuginfodConfig::default(), + analysis_cache: DwarfAnalysisCacheConfig::default(), + } + } +} + +impl Default for DwarfAnalysisCacheConfig { + fn default() -> Self { + Self { + enabled: default_analysis_cache_enabled(), + directory: None, } } } diff --git a/ghostscope/src/config/user.rs b/ghostscope/src/config/user.rs index 9d483d41..4483cc42 100644 --- a/ghostscope/src/config/user.rs +++ b/ghostscope/src/config/user.rs @@ -33,6 +33,7 @@ pub struct UserConfig { pub tui_mode: bool, pub dry_run: bool, pub dry_run_details: bool, + pub prepare: bool, // File saving options pub should_save_llvm_ir: bool, @@ -53,6 +54,7 @@ pub struct UserConfig { pub dwarf_search_paths: Vec, pub dwarf_allow_loose_debug_match: bool, pub dwarf_debuginfod: ResolvedDebuginfodConfig, + pub dwarf_analysis_cache_dir: Option, // eBPF configuration pub ebpf_config: crate::config::settings::EbpfConfig, @@ -68,6 +70,12 @@ impl UserConfig { /// Create merged user configuration from parsed arguments and config file. pub fn new(args: ParsedArgs, config: Config) -> Self { let dwarf_debuginfod = ResolvedDebuginfodConfig::resolve(&args, &config); + let dwarf_analysis_cache_dir = resolve_analysis_cache_directory( + args.no_analysis_cache, + args.analysis_cache_dir.clone(), + config.dwarf.analysis_cache.enabled, + config.dwarf.analysis_cache.directory.clone(), + ); let log_file = args .log_file .unwrap_or_else(|| PathBuf::from(&config.general.log_file)); @@ -174,6 +182,7 @@ impl UserConfig { tui_mode, dry_run: args.dry_run, dry_run_details: args.dry_run_details, + prepare: args.prepare, should_save_llvm_ir, should_save_ebpf, should_save_ast, @@ -192,6 +201,7 @@ impl UserConfig { config.dwarf.allow_loose_debug_match }, dwarf_debuginfod, + dwarf_analysis_cache_dir, ebpf_config: { let mut ebpf_config = config.ebpf; if args.force_perf_event_array { @@ -295,6 +305,41 @@ impl UserConfig { info!("✓ Command line arguments validated successfully"); Ok(()) } + + pub(crate) fn validate_prepare(&self) -> Result<()> { + if !self.prepare { + return Ok(()); + } + if self.input_pid.is_some() { + return Err(anyhow::anyhow!( + "--prepare accepts --target only; PID and runtime state are resolved when tracing" + )); + } + if self.target_path.is_none() { + return Err(anyhow::anyhow!("--prepare requires --target ")); + } + if self.binary_path.is_some() || !self.binary_args.is_empty() { + return Err(anyhow::anyhow!( + "--prepare does not launch a target program; use --target " + )); + } + if self.script.is_some() || self.script_file.is_some() { + return Err(anyhow::anyhow!( + "--prepare caches target analysis and does not accept a trace script" + )); + } + if self.dry_run { + return Err(anyhow::anyhow!( + "--prepare and --dry-run cannot be used together" + )); + } + if self.dwarf_analysis_cache_dir.is_none() { + return Err(anyhow::anyhow!( + "--prepare requires analysis caching; remove --no-analysis-cache or enable dwarf.analysis_cache" + )); + } + self.validate_with_pid_state(false) + } } /// Fully resolved debuginfod configuration. @@ -465,9 +510,58 @@ fn default_enable_logging_for_mode(is_script_mode: bool) -> bool { !is_script_mode } +fn resolve_analysis_cache_directory( + disabled: bool, + cli_directory: Option, + config_enabled: bool, + config_directory: Option, +) -> Option { + if disabled { + return None; + } + + cli_directory + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| { + config_enabled + .then_some(config_directory) + .flatten() + .filter(|path| !path.as_os_str().is_empty()) + }) + .or_else(|| config_enabled.then(ghostscope_dwarf::AnalysisCache::default_directory)) +} + fn is_pid_running(pid: u32) -> bool { use std::path::Path; let proc_path = format!("/proc/{pid}"); Path::new(&proc_path).is_dir() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_configured_analysis_cache_directory_uses_default() { + let resolved = resolve_analysis_cache_directory(false, None, true, Some(PathBuf::new())); + + assert_eq!( + resolved, + Some(ghostscope_dwarf::AnalysisCache::default_directory()) + ); + } + + #[test] + fn empty_cli_analysis_cache_directory_follows_config() { + let configured = PathBuf::from("/configured/analysis-cache"); + let resolved = resolve_analysis_cache_directory( + false, + Some(PathBuf::new()), + true, + Some(configured.clone()), + ); + + assert_eq!(resolved, Some(configured)); + } +} diff --git a/ghostscope/src/core/session.rs b/ghostscope/src/core/session.rs index b67ab238..2fbf066d 100644 --- a/ghostscope/src/core/session.rs +++ b/ghostscope/src/core/session.rs @@ -1,10 +1,12 @@ -use crate::config::{ParsedArgs, PidViews, ResolvedConfig}; +use crate::config::{ParsedArgs, PidViews, ResolvedConfig, ResolvedDebuginfodConfig}; use crate::source_path::SourcePathResolver; use crate::trace::backtrace_runtime::BacktraceRuntimeRunner; use crate::trace::TraceManager; use anyhow::Result; use ghostscope_debuginfod::{DebuginfodClient, DebuginfodConfig}; -use ghostscope_dwarf::{DwarfAnalyzer, ExplicitDebugFile, ModuleStats}; +use ghostscope_dwarf::{ + AnalysisCache, DwarfAnalyzer, DwarfLoadOptions, ExplicitDebugFile, ModuleStats, +}; use ghostscope_process::{ PidFilterSpec, PidNamespaceId, ProcessManager, ProcessSysmon, SysEventKind, SysmonConfig, SysmonEventMask, @@ -38,6 +40,43 @@ fn target_mode_map_change_unfiltered(target: &Path) -> bool { ghostscope_process::is_shared_object(target) } +pub(crate) fn build_debuginfod_client( + debuginfod: &ResolvedDebuginfodConfig, +) -> Result>> { + if !debuginfod.is_effectively_enabled() { + return Ok(None); + } + + let Some(cache_dir) = debuginfod.cache_dir.as_ref() else { + warn!("debuginfod is enabled but no cache directory was resolved; skipping"); + return Ok(None); + }; + + let mut client_config = + DebuginfodConfig::new(debuginfod.urls.iter().map(String::as_str), cache_dir)?; + client_config = match debuginfod.timeout_secs { + Some(timeout_secs) => client_config.with_timeout(Duration::from_secs(timeout_secs)), + None => client_config.without_timeout(), + }; + client_config = client_config.with_max_size(debuginfod.max_size_bytes); + + info!( + "debuginfod fallback enabled: urls={}, cache_dir={}, timeout_secs={}, max_size_bytes={}", + debuginfod.urls.len(), + cache_dir.display(), + debuginfod + .timeout_secs + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + debuginfod + .max_size_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + + Ok(Some(Arc::new(DebuginfodClient::new(client_config)?))) +} + #[derive(Debug, Clone)] pub struct RuntimePidContext { /// PID used for `/proc//...` access and DWARF loading in GhostScope's current view. @@ -306,43 +345,25 @@ impl GhostSession { Some(ExplicitDebugFile::new(target_module, debug_file)) } + fn dwarf_load_options(&self) -> Result { + Ok(DwarfLoadOptions { + debug_search_paths: self.get_debug_search_paths(), + allow_loose_debug_match: self.get_allow_loose_debug_match(), + explicit_debug_file: self.explicit_debug_file_for_target(), + debuginfod_client: self.build_debuginfod_client()?, + analysis_cache: self + .config + .as_ref() + .and_then(|config| config.dwarf_analysis_cache_dir.as_ref()) + .map(AnalysisCache::read_only), + }) + } + fn build_debuginfod_client(&self) -> Result>> { let Some(config) = self.config.as_ref() else { return Ok(None); }; - let debuginfod = &config.dwarf_debuginfod; - if !debuginfod.is_effectively_enabled() { - return Ok(None); - } - - let Some(cache_dir) = debuginfod.cache_dir.as_ref() else { - warn!("debuginfod is enabled but no cache directory was resolved; skipping"); - return Ok(None); - }; - - let mut client_config = - DebuginfodConfig::new(debuginfod.urls.iter().map(String::as_str), cache_dir)?; - client_config = match debuginfod.timeout_secs { - Some(timeout_secs) => client_config.with_timeout(Duration::from_secs(timeout_secs)), - None => client_config.without_timeout(), - }; - client_config = client_config.with_max_size(debuginfod.max_size_bytes); - - info!( - "debuginfod fallback enabled: urls={}, cache_dir={}, timeout_secs={}, max_size_bytes={}", - debuginfod.urls.len(), - cache_dir.display(), - debuginfod - .timeout_secs - .map(|value| value.to_string()) - .unwrap_or_else(|| "none".to_string()), - debuginfod - .max_size_bytes - .map(|value| value.to_string()) - .unwrap_or_else(|| "none".to_string()) - ); - - Ok(Some(Arc::new(DebuginfodClient::new(client_config)?))) + build_debuginfod_client(&config.dwarf_debuginfod) } fn ensure_pid_runtime_modules( @@ -560,22 +581,16 @@ impl GhostSession { pub async fn load_binary_parallel(&mut self) -> Result<()> { info!("Loading binary and performing DWARF analysis (parallel mode)"); - let debug_search_paths = self.get_debug_search_paths(); - let allow_loose = self.get_allow_loose_debug_match(); - let explicit_debug_file = self.explicit_debug_file_for_target(); - let debuginfod_client = self.build_debuginfod_client()?; + let load_options = self.dwarf_load_options()?; let process_analyzer = if let Some(proc_pid) = self.proc_pid() { info!("Loading binary from PID: {} (parallel)", proc_pid); let runtime_modules = self.ensure_pid_runtime_modules(proc_pid)?; Some( - DwarfAnalyzer::from_pid_runtime_modules_with_config_debuginfod_and_explicit_debug_file( + DwarfAnalyzer::from_pid_runtime_modules_with_options_and_progress( proc_pid, runtime_modules, - &debug_search_paths, - allow_loose, - debuginfod_client.clone(), - explicit_debug_file.clone(), + load_options, |_| {}, ) .await?, @@ -583,12 +598,9 @@ impl GhostSession { } else if let Some(ref binary_path) = self.target_binary { info!("Loading binary from executable path: {}", binary_path); Some( - DwarfAnalyzer::from_exec_path_with_config_debuginfod_explicit_debug_file_and_progress( + DwarfAnalyzer::from_exec_path_with_options_and_progress( binary_path, - &debug_search_paths, - allow_loose, - debuginfod_client.clone(), - explicit_debug_file.map(|explicit| explicit.debug_file), + load_options, |_| {}, ) .await?, @@ -612,10 +624,7 @@ impl GhostSession { { info!("Loading binary and performing DWARF analysis (parallel mode with progress)"); - let debug_search_paths = self.get_debug_search_paths(); - let allow_loose = self.get_allow_loose_debug_match(); - let explicit_debug_file = self.explicit_debug_file_for_target(); - let debuginfod_client = self.build_debuginfod_client()?; + let load_options = self.dwarf_load_options()?; let process_analyzer = if let Some(proc_pid) = self.proc_pid() { info!( @@ -624,13 +633,10 @@ impl GhostSession { ); let runtime_modules = self.ensure_pid_runtime_modules(proc_pid)?; Some( - DwarfAnalyzer::from_pid_runtime_modules_with_config_debuginfod_and_explicit_debug_file( + DwarfAnalyzer::from_pid_runtime_modules_with_options_and_progress( proc_pid, runtime_modules, - &debug_search_paths, - allow_loose, - debuginfod_client.clone(), - explicit_debug_file.clone(), + load_options, progress_callback, ) .await?, @@ -638,12 +644,9 @@ impl GhostSession { } else if let Some(ref binary_path) = self.target_binary { info!("Loading binary from executable path: {}", binary_path); Some( - DwarfAnalyzer::from_exec_path_with_config_debuginfod_explicit_debug_file_and_progress( + DwarfAnalyzer::from_exec_path_with_options_and_progress( binary_path, - &debug_search_paths, - allow_loose, - debuginfod_client.clone(), - explicit_debug_file.map(|explicit| explicit.debug_file), + load_options, progress_callback, ) .await?, @@ -775,6 +778,9 @@ mod tests { tui_mode: false, dry_run: false, dry_run_details: false, + prepare: false, + analysis_cache_dir: None, + no_analysis_cache: false, script_output: None, status_enabled: true, has_explicit_status_flag: false, diff --git a/ghostscope/src/main.rs b/ghostscope/src/main.rs index 58a8a8fc..87c54257 100644 --- a/ghostscope/src/main.rs +++ b/ghostscope/src/main.rs @@ -49,6 +49,10 @@ async fn main() -> Result<()> { info!("{}", user_config.config_source_message()); + if user_config.prepare { + return cli::prepare_analysis_cache(&user_config).await; + } + // Dry-run does not attach uprobes, but it still validates the same eBPF // privileges and kernel capabilities as a real run. crate::util::ensure_privileges(); diff --git a/ghostscope/src/tui/dwarf_loader.rs b/ghostscope/src/tui/dwarf_loader.rs index c16d3d3c..f4c02545 100644 --- a/ghostscope/src/tui/dwarf_loader.rs +++ b/ghostscope/src/tui/dwarf_loader.rs @@ -1,8 +1,13 @@ use crate::config::ResolvedConfig; use crate::core::GhostSession; use anyhow::Result; -use ghostscope_dwarf::ModuleLoadingEvent; -use ghostscope_ui::{events::ModuleLoadingStats as UIModuleLoadingStats, RuntimeStatus}; +use ghostscope_dwarf::{AnalysisCacheStatus, ModuleLoadingEvent}; +use ghostscope_ui::{ + events::{ + AnalysisCacheStatus as UIAnalysisCacheStatus, ModuleLoadingStats as UIModuleLoadingStats, + }, + RuntimeStatus, +}; use tracing::info; /// Convert ModuleLoadingEvent to RuntimeStatus @@ -38,6 +43,14 @@ fn convert_loading_event_to_runtime_status(event: ModuleLoadingEvent) -> Runtime debug_source: stats.debug_info_source.kind_label().to_string(), debug_source_path: stats.debug_info_source.display_path().map(str::to_string), load_time_ms: stats.load_time_ms, + analysis_cache_status: match stats.analysis_cache_status { + AnalysisCacheStatus::Disabled => UIAnalysisCacheStatus::Disabled, + AnalysisCacheStatus::Hit => UIAnalysisCacheStatus::Hit, + AnalysisCacheStatus::Miss => UIAnalysisCacheStatus::Miss, + AnalysisCacheStatus::Rejected { reason } => { + UIAnalysisCacheStatus::Rejected { reason } + } + }, }; RuntimeStatus::DwarfModuleLoadingCompleted { module_path,