From 36201d2c6920f77365c061ab3e2cb0edf519c14e Mon Sep 17 00:00:00 2001 From: swananan Date: Sat, 11 Jul 2026 14:07:14 +0800 Subject: [PATCH] feat: add persistent DWARF analysis cache Add a rootless prepare mode that serializes target-independent DWARF analysis and reuses it during later CLI and TUI startup. Validate cache identity and bounds, fall back to raw DWARF for unusable entries, and report hit, miss, and rejection states. --- Cargo.lock | 11 + Cargo.toml | 1 + config-zh.toml | 9 + config.toml | 10 + docs/configuration.md | 35 + docs/zh/configuration.md | 34 + e2e-tests/tests/backtrace_execution.rs | 144 ++ .../tests/startup_load_report_execution.rs | 225 +++ ghostscope-dwarf/Cargo.toml | 2 + ghostscope-dwarf/src/analysis_cache.rs | 1276 +++++++++++++++++ ghostscope-dwarf/src/analyzer/mod.rs | 92 +- ghostscope-dwarf/src/analyzer/types.rs | 16 + .../src/index/lightweight_file_index.rs | 18 + .../src/index/lightweight_index.rs | 59 + ghostscope-dwarf/src/index/line_mapping.rs | 432 +++++- ghostscope-dwarf/src/index/mod.rs | 4 +- ghostscope-dwarf/src/lib.rs | 12 +- ghostscope-dwarf/src/loader.rs | 25 + .../src/objfile/function_lookup.rs | 9 +- ghostscope-dwarf/src/objfile/loaded.rs | 6 + ghostscope-dwarf/src/objfile/loading.rs | 111 +- .../src/objfile/source_location.rs | 34 +- ghostscope-dwarf/src/parser/mod.rs | 3 +- .../src/components/app/runtime_status.rs | 2 + .../src/components/loading/progress.rs | 56 + ghostscope-ui/src/components/loading/state.rs | 42 + ghostscope-ui/src/components/loading/ui.rs | 114 ++ ghostscope-ui/src/events.rs | 6 +- ghostscope-ui/src/events/runtime.rs | 9 + ghostscope/src/cli/loading_reporter.rs | 155 +- ghostscope/src/cli/mod.rs | 3 + ghostscope/src/cli/prepare.rs | 60 + ghostscope/src/cli/script_runtime.rs | 2 + ghostscope/src/config/args.rs | 48 +- ghostscope/src/config/mod.rs | 2 +- ghostscope/src/config/settings.rs | 27 + ghostscope/src/config/user.rs | 94 ++ ghostscope/src/core/session.rs | 132 +- ghostscope/src/main.rs | 4 + ghostscope/src/tui/dwarf_loader.rs | 17 +- 40 files changed, 3189 insertions(+), 152 deletions(-) create mode 100644 ghostscope-dwarf/src/analysis_cache.rs create mode 100644 ghostscope/src/cli/prepare.rs 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,