Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 30 additions & 40 deletions ghostscope-dwarf/src/analyzer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub struct DwarfAnalyzer {
pid: u32,
/// Module path -> module data mapping
modules: HashMap<PathBuf, LoadedObjfile>,
/// The explicit target, or the loaded module identified by /proc/PID/exe.
/// Keep this identity stable as shared libraries are discovered later.
main_module: Option<PathBuf>,
/// Bounded ELF symbol indexes for runtime modules loaded without full DWARF.
/// Cookies preserve reusable module identity across paths and PID roots.
runtime_text_symbols: HashMap<u64, Vec<RuntimeTextSymbol>>,
Expand Down Expand Up @@ -465,6 +468,7 @@ impl DwarfAnalyzer {
}

if new_runtime_modules.is_empty() {
self.resolve_main_module_if_missing();
return Ok(0);
}

Expand Down Expand Up @@ -501,6 +505,7 @@ impl DwarfAnalyzer {
let module_path = module.module_path().clone();
self.modules.insert(module_path, module);
}
self.resolve_main_module_if_missing();

if loaded_count > 0 {
self.clear_pc_context_cache();
Expand Down Expand Up @@ -704,6 +709,7 @@ impl DwarfAnalyzer {
let mut analyzer = Self {
pid: 0, // No specific PID in exec mode
modules: HashMap::new(),
main_module: Some(exec_path.clone()),
runtime_text_symbols: HashMap::new(),
pc_context_cache: RwLock::new(PcContextCache::default()),
};
Expand Down Expand Up @@ -790,6 +796,7 @@ impl DwarfAnalyzer {
let mut analyzer = Self {
pid,
modules: HashMap::new(),
main_module: None,
runtime_text_symbols: HashMap::new(),
pc_context_cache: RwLock::new(PcContextCache::default()),
};
Expand All @@ -799,6 +806,8 @@ impl DwarfAnalyzer {
analyzer.modules.insert(module_path, module);
}

analyzer.resolve_main_module_if_missing();

tracing::info!(
"Created DWARF analyzer for PID {} with {} pre-loaded modules",
pid,
Expand All @@ -808,6 +817,16 @@ impl DwarfAnalyzer {
analyzer
}

/// Retry unresolved PID identity while preserving an already selected target.
fn resolve_main_module_if_missing(&mut self) {
if self.pid == 0 || self.main_module.is_some() {
return;
}

let executable = PathBuf::from(format!("/proc/{}/exe", self.pid));
self.main_module = self.loaded_module_path_for(&executable).cloned();
}

fn clear_pc_context_cache(&self) {
if let Ok(mut cache) = self.pc_context_cache.write() {
*cache = PcContextCache::default();
Expand Down Expand Up @@ -1273,32 +1292,16 @@ impl DwarfAnalyzer {
}
}

/// Get main executable module information
/// Get the process executable, or the explicitly selected target in -t mode.
pub fn get_main_executable(&self) -> Option<MainExecutableInfo> {
// Find the main executable module (usually the first non-library module)
for module_path in self.modules.keys() {
if self.is_main_executable_module(module_path) {
return Some(MainExecutableInfo {
path: module_path.to_string_lossy().to_string(),
});
}
}
None
self.main_module.as_ref().map(|path| MainExecutableInfo {
path: path.to_string_lossy().to_string(),
})
}

/// Check if a module is the main executable (not a shared library)
/// Check the identity captured at construction, without guessing from names.
fn is_main_executable_module(&self, module_path: &Path) -> bool {
// Heuristic: main executable usually doesn't have .so extension and contains the process name
let filename = module_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");

// Not a shared library
!filename.contains(".so") &&
// Not a system library path
!module_path.to_string_lossy().starts_with("/lib") &&
!module_path.to_string_lossy().starts_with("/usr/lib")
self.main_module.as_deref() == Some(module_path)
}

/// Get list of all function names across all modules
Expand Down Expand Up @@ -1371,13 +1374,8 @@ impl DwarfAnalyzer {

/// Get executable file information (for "info file" command)
pub fn get_executable_file_info(&self) -> Option<ExecutableFileInfo> {
// Find the primary executable (not a shared library)
let executable = self
.modules
.iter()
.find(|(path, _)| !self.is_shared_library(path))?;

let (exe_path, module_data) = executable;
let exe_path = self.main_module.as_ref()?;
let module_data = self.modules.get(exe_path)?;
let file_path = exe_path.to_string_lossy().to_string();

// Parse the ELF file to get detailed information
Expand Down Expand Up @@ -1411,7 +1409,7 @@ impl DwarfAnalyzer {

// Load bias for PID mode from module mapping (if available)
let load_bias = if self.pid != 0 {
module_data.module_mapping().loaded_address.unwrap_or(0)
module_data.module_mapping().load_bias.unwrap_or(0)
} else {
0
};
Expand Down Expand Up @@ -1463,17 +1461,9 @@ impl DwarfAnalyzer {

// NOTE: Runtime section offsets are handled by ghostscope-coordinator.

/// Check if a module is a shared library
/// Modules other than the selected target are displayed as dependencies.
fn is_shared_library(&self, module_path: &Path) -> bool {
let filename = module_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");

// Shared libraries typically have .so extension or contain .so
filename.contains(".so")
|| module_path.to_string_lossy().starts_with("/lib")
|| module_path.to_string_lossy().starts_with("/usr/lib")
!self.is_main_executable_module(module_path)
}

/// Get grouped file info by module (compatibility method)
Expand Down
161 changes: 161 additions & 0 deletions ghostscope-dwarf/tests/main_module_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use ghostscope_dwarf::{DwarfAnalyzer, ModuleDefaultPolicy, ModuleLoadingEvent};
use object::Object;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};

struct Target(std::process::Child);

impl Drop for Target {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}

fn compile_target(directory: &Path, name: &str, shared: bool) -> std::path::PathBuf {
let source = directory.join("target.c");
std::fs::write(
&source,
"#include <unistd.h>\nint value = 7;\nint main(void) { write(STDOUT_FILENO, \"ready\\n\", 6); for (;;) pause(); }\n",
)
.unwrap();
let binary = directory.join(name);
let mut compiler = Command::new("cc");
compiler.args(["-g", "-O0"]);
if shared {
compiler.args(["-shared", "-fPIC"]);
} else {
compiler.arg("-no-pie");
}
let output = compiler
.arg(&source)
.arg("-o")
.arg(&binary)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
binary
}

fn start_target(binary: &Path) -> Target {
let mut target = Target(
Command::new(binary)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let mut ready = String::new();
BufReader::new(target.0.stdout.take().unwrap())
.read_line(&mut ready)
.unwrap();
assert_eq!(
ready, "ready\n",
"target should finish startup before discovery"
);
target
}

fn assert_default_module(analyzer: &DwarfAnalyzer, binary: &Path) {
assert_eq!(
Path::new(&analyzer.get_main_executable().unwrap().path),
binary
);
assert_eq!(
analyzer
.resolve_address_module(None, None, ModuleDefaultPolicy::MainExecutableOnly)
.unwrap(),
binary
);
assert!(analyzer.get_executable_file_info().is_some());
assert!(analyzer
.get_shared_library_info()
.iter()
.all(|module| Path::new(&module.library_path) != binary));
}

#[tokio::test]
async fn explicit_target_identity_does_not_depend_on_filename() {
let directory = tempfile::tempdir().unwrap();
for (name, shared) in [("worker.software", false), ("plugin.so", true)] {
let binary = compile_target(directory.path(), name, shared);
let analyzer = DwarfAnalyzer::from_exec_path(&binary).await.unwrap();
assert_default_module(&analyzer, &binary);
}
}

#[tokio::test]
async fn pid_default_module_comes_from_the_process_executable() {
let directory = tempfile::tempdir().unwrap();
let binary = compile_target(directory.path(), "worker.software", false);
let target = start_target(&binary);
let analyzer = DwarfAnalyzer::from_pid(target.0.id()).await.unwrap();
assert_default_module(&analyzer, &binary);
assert_eq!(analyzer.get_module_stats().executable_modules, 1);
let bytes = std::fs::read(&binary).unwrap();
let elf = object::File::parse(bytes.as_slice()).unwrap();
assert_eq!(
analyzer.get_executable_file_info().unwrap().entry_point,
Some(elf.entry()),
"a non-PIE executable has zero load bias, despite its nonzero mapping base"
);
}

#[tokio::test]
async fn pid_default_module_recovers_after_its_path_is_restored() {
for remove_before_load in [true, false] {
let directory = tempfile::tempdir().unwrap();
let binary = compile_target(directory.path(), "worker.software", false);
let backup = directory.path().join("backup");
std::fs::hard_link(&binary, &backup).unwrap();
let target = start_target(&binary);

// Remove the path either before discovery or just after loading its ELF.
// Both leave main identity unresolved, but only the first needs a new module.
if remove_before_load {
std::fs::remove_file(&binary).unwrap();
}
let callback_binary = binary.clone();
let mut analyzer =
DwarfAnalyzer::from_pid_parallel_with_progress(target.0.id(), move |event| {
if let ModuleLoadingEvent::LoadingCompleted { module_path, .. } = event {
if !remove_before_load && Path::new(&module_path) == callback_binary {
std::fs::remove_file(&callback_binary).unwrap();
}
}
})
.await
.unwrap();
assert!(analyzer.get_main_executable().is_none());
assert_eq!(
analyzer.module_paths().contains(&binary),
!remove_before_load
);

// Restore the same inode while the original process continues running.
std::fs::hard_link(&backup, &binary).unwrap();
assert!(DwarfAnalyzer::module_paths_equivalent(
&binary,
format!("/proc/{}/exe", target.0.id())
));
let runtime_modules = DwarfAnalyzer::discover_pid_runtime_modules(target.0.id()).unwrap();
let loaded = analyzer
.refresh_pid_runtime_modules_with_config_and_debuginfod(
runtime_modules,
&[],
false,
None,
|_| {},
)
.await
.unwrap();
assert_eq!(loaded, usize::from(remove_before_load));
assert_default_module(&analyzer, &binary);
assert_eq!(analyzer.get_module_stats().executable_modules, 1);
}
}
Loading