From 2c999149d63182c682313509a09c214472137e9e Mon Sep 17 00:00:00 2001 From: swananan Date: Sun, 6 Sep 2026 12:31:04 +0800 Subject: [PATCH] fix: reject ambiguous module load instances --- e2e-tests/tests/common/mod.rs | 36 ++ .../backtrace_dlopen_program/Makefile | 10 +- .../load_instance_lib.c | 7 + .../load_instance_program.c | 114 +++++ e2e-tests/tests/globals_target_execution.rs | 35 +- e2e-tests/tests/load_instance_execution.rs | 225 ++++++++++ .../src/ebpf/helper_functions.rs | 61 ++- ghostscope-process/src/lib.rs | 4 +- ghostscope-process/src/offsets.rs | 397 ++++++++++++++++-- .../src/sysmon/offset_refresh.rs | 65 ++- ghostscope/src/script/attach.rs | 10 +- ghostscope/src/script/cli.rs | 2 +- ghostscope/src/script/runtime_maps.rs | 4 +- ghostscope/src/script/tui.rs | 2 +- 14 files changed, 870 insertions(+), 102 deletions(-) create mode 100644 e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_lib.c create mode 100644 e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_program.c create mode 100644 e2e-tests/tests/load_instance_execution.rs diff --git a/e2e-tests/tests/common/mod.rs b/e2e-tests/tests/common/mod.rs index a15cb90c..5f7aeeec 100644 --- a/e2e-tests/tests/common/mod.rs +++ b/e2e-tests/tests/common/mod.rs @@ -1266,6 +1266,40 @@ lazy_static! { pub static ref FIXTURES: TestFixtures = TestFixtures::new(); } +#[allow(dead_code)] +pub fn skip_if_nested_t_mode_unsupported() -> bool { + use std::env; + + let target_mode = match env::var("E2E_TARGET_MODE") { + Ok(value) => value, + Err(env::VarError::NotPresent) => return false, + Err(err) => { + eprintln!("continuing nested -t test despite unreadable E2E_TARGET_MODE: {err}"); + return false; + } + }; + let nested_child_container = matches!( + target_mode.trim().to_ascii_lowercase().as_str(), + "child-container" | "child" | "nested" | "descendant" + ); + if !nested_child_container { + return false; + } + + // Nested child-container globals `-t` is intentionally unsupported for now. + // The current `-t` implementation relies on target-path / sysmon lifecycle + // maintenance anchored in GhostScope's current `/proc` view, while nested + // child-container targets introduce a second PID namespace below that view. + // These globals tests need a stable runtime-pid -> outer `/proc` pid mapping. + // Backtrace has separate alias setup and retains its nested coverage. + eprintln!( + "skipping nested child-container -t test: nested target-path mode is \ + currently unsupported because proc offsets and runtime lifecycle \ + maintenance stay anchored in the outer container /proc pid view" + ); + true +} + // Re-export the shared runner for convenience in tests pub mod runner; pub mod rust_toolchain; @@ -1549,6 +1583,8 @@ fn ensure_backtrace_dlopen_program_compiled() -> anyhow::Result<()> { &[ base.join("backtrace_dlopen_program"), base.join("libbacktrace_dlopen_target.so"), + base.join("load_instance_program"), + base.join("libload_instance.so"), ], ) { return result; diff --git a/e2e-tests/tests/fixtures/backtrace_dlopen_program/Makefile b/e2e-tests/tests/fixtures/backtrace_dlopen_program/Makefile index 3581f7f1..da2f4d45 100644 --- a/e2e-tests/tests/fixtures/backtrace_dlopen_program/Makefile +++ b/e2e-tests/tests/fixtures/backtrace_dlopen_program/Makefile @@ -6,7 +6,13 @@ OBJ ?= $(BINARY).o SHARED_LIB ?= libbacktrace_dlopen_target.so SHARED_OBJ ?= backtrace_dlopen_lib.o -all: $(BINARY) $(SHARED_LIB) +all: $(BINARY) $(SHARED_LIB) load_instance_program libload_instance.so + +load_instance_program: load_instance_program.c + $(CC) $(BASE_CFLAGS) -O0 -o $@ $< -ldl + +libload_instance.so: load_instance_lib.c + $(CC) $(BASE_CFLAGS) -O0 -fPIC -shared -Wl,--build-id -o $@ $< $(BINARY): $(OBJ) $(CC) $(BASE_CFLAGS) -O0 -fomit-frame-pointer -o $@ $(OBJ) -ldl @@ -22,6 +28,6 @@ $(SHARED_OBJ): backtrace_dlopen_lib.c backtrace_dlopen_lib.h clean: rm -f *.o backtrace_dlopen_program libbacktrace_dlopen_target.so dlopen.trigger \ - dlopen.trigger.pending dlopen.after_limit.trigger + dlopen.trigger.pending dlopen.after_limit.trigger load_instance_program libload_instance.so .PHONY: all clean diff --git a/e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_lib.c b/e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_lib.c new file mode 100644 index 00000000..2a5a3cee --- /dev/null +++ b/e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_lib.c @@ -0,0 +1,7 @@ +volatile int instance_marker = 0; + +__attribute__((noinline)) int instance_tick(int expected) +{ + asm volatile("" ::: "memory"); + return instance_marker == expected; +} diff --git a/e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_program.c b/e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_program.c new file mode 100644 index 00000000..723e3c0a --- /dev/null +++ b/e2e-tests/tests/fixtures/backtrace_dlopen_program/load_instance_program.c @@ -0,0 +1,114 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t running = 1; + +static void stop(int signo) +{ + (void)signo; + running = 0; +} + +static void *load_instance(const char *path, int marker, int (**tick)(int)) +{ + void *handle = dlmopen(LM_ID_NEWLM, path, RTLD_NOW | RTLD_LOCAL); + if (!handle) { + fprintf(stderr, "dlmopen: %s\n", dlerror()); + exit(2); + } + volatile int *value = dlsym(handle, "instance_marker"); + *tick = dlsym(handle, "instance_tick"); + if (!value || !*tick) { + fprintf(stderr, "missing instance symbols\n"); + exit(2); + } + *value = marker; + return handle; +} + +static void mark_ready(int count) +{ + FILE *file = fopen("instance.ready", "w"); + if (!file) { + perror("instance.ready"); + exit(2); + } + fprintf(file, "%d\n", count); + fclose(file); +} + +static void *map_read_only_copy(int (*first)(int), size_t page_size) +{ + int fd = open("./libload_instance.so", O_RDONLY); + if (fd < 0) { + perror("open library"); + exit(2); + } + uintptr_t page = (uintptr_t)first & ~(uintptr_t)(page_size - 1); + void *mapping = MAP_FAILED; + for (uintptr_t gap = 16U << 20; gap <= 256U << 20; gap += 16U << 20) { + mapping = mmap((void *)(page - gap), page_size, PROT_READ, + MAP_PRIVATE | MAP_FIXED_NOREPLACE, fd, 0); + if (mapping != MAP_FAILED) { + break; + } + } + close(fd); + if (mapping == MAP_FAILED) { + perror("mmap read-only library"); + exit(2); + } + return mapping; +} + +int main(void) +{ + signal(SIGTERM, stop); + signal(SIGINT, stop); + int (*first)(int) = NULL; + int (*second)(int) = NULL; + void *one = load_instance("./libload_instance.so", 11, &first); + void *two = NULL; + size_t page_size = (size_t)sysconf(_SC_PAGESIZE); + void *read_only = NULL; + if (access("instance.readonly", F_OK) == 0) { + read_only = map_read_only_copy(first, page_size); + } + mark_ready(1); + while (running) { + if (!two && access("instance.trigger", F_OK) == 0) { + const char *path = access("instance.copy", F_OK) == 0 + ? "./libload_instance_copy.so" : "./libload_instance.so"; + two = load_instance(path, 22, &second); + FILE *pc_file = fopen("instance.second_pc", "w"); + if (!pc_file) { + perror("instance.second_pc"); + return 2; + } + fprintf(pc_file, "%llu\n", (unsigned long long)(uintptr_t)second); + fclose(pc_file); + mark_ready(2); + } + if (!first(11) || (second && !second(22))) { + fprintf(stderr, "native instance value mismatch\n"); + return 3; + } + usleep(5000); + } + if (two) { + dlclose(two); + } + dlclose(one); + if (read_only) { + munmap(read_only, page_size); + } + return 0; +} diff --git a/e2e-tests/tests/globals_target_execution.rs b/e2e-tests/tests/globals_target_execution.rs index 5437d386..341635b8 100644 --- a/e2e-tests/tests/globals_target_execution.rs +++ b/e2e-tests/tests/globals_target_execution.rs @@ -3,10 +3,9 @@ mod common; -use common::{init, FIXTURES}; +use common::{init, skip_if_nested_t_mode_unsupported, FIXTURES}; use regex::Regex; use serial_test::serial; -use std::env; use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; use std::sync::{ @@ -124,38 +123,6 @@ fn workspace_root() -> anyhow::Result { .ok_or_else(|| anyhow::anyhow!("failed to resolve workspace root")) } -fn skip_if_nested_t_mode_unsupported() -> bool { - let target_mode = match env::var("E2E_TARGET_MODE") { - Ok(value) => value, - Err(env::VarError::NotPresent) => return false, - Err(err) => { - eprintln!("continuing nested -t test despite unreadable E2E_TARGET_MODE: {err}"); - return false; - } - }; - let nested_child_container = matches!( - target_mode.trim().to_ascii_lowercase().as_str(), - "child-container" | "child" | "nested" | "descendant" - ); - if !nested_child_container { - return false; - } - - // Nested child-container `-t` is intentionally unsupported for now. - // The current `-t` implementation relies on target-path / sysmon lifecycle - // maintenance anchored in GhostScope's current `/proc` view, while nested - // child-container targets introduce a second PID namespace below that view. - // Without a stable, shared runtime-pid -> outer `/proc` pid mapping source, - // these tests are not a reliable correctness signal, so skip the entire - // nested `-t` suite instead of depending on CLI-only heuristics. - eprintln!( - "skipping nested child-container -t test: nested target-path mode is \ - currently unsupported because proc offsets and runtime lifecycle \ - maintenance stay anchored in the outer container /proc pid view" - ); - true -} - // Late-start helper: run GhostScope first, wait until the CLI reports it has // finished compile/load/attach, then start the target process. async fn run_ghostscope_then_start_target_after_ready( diff --git a/e2e-tests/tests/load_instance_execution.rs b/e2e-tests/tests/load_instance_execution.rs new file mode 100644 index 00000000..4e968c1c --- /dev/null +++ b/e2e-tests/tests/load_instance_execution.rs @@ -0,0 +1,225 @@ +mod common; + +use anyhow::{ensure, Context, Result}; +use common::runner::GhostscopeRunner; +use common::targets::{TargetHandle, TargetLauncher}; +use std::path::Path; +use std::time::Duration; + +const SCRIPT: &str = + r#"trace instance_tick { print "INSTANCE {}:{}", expected, instance_marker; }"#; + +async fn wait_for_instances(directory: &Path, count: u32) -> Result<()> { + tokio::time::timeout(Duration::from_secs(15), async { + loop { + if std::fs::read_to_string(directory.join("instance.ready")) + .is_ok_and(|value| value.trim() == count.to_string()) + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .context("target did not finish loading its instances")?; + Ok(()) +} + +async fn spawn_instances( + initial_count: u32, + copy_image: bool, + read_only_copy: bool, +) -> Result<(tempfile::TempDir, TargetHandle)> { + let fixture = common::FIXTURES.get_test_binary("backtrace_dlopen_program")?; + let base = fixture.parent().context("fixture has no parent")?; + // Keep each test's ELF inode and trigger files independent, inside the shared + // workspace mount so this also exercises host-to-private-container tracing. + let directory = tempfile::Builder::new() + .prefix(".load-instances-") + .tempdir_in(base)?; + std::fs::copy( + base.join("libload_instance.so"), + directory.path().join("libload_instance.so"), + )?; + if copy_image { + std::fs::copy( + directory.path().join("libload_instance.so"), + directory.path().join("libload_instance_copy.so"), + )?; + std::fs::write(directory.path().join("instance.copy"), "")?; + } + if initial_count == 2 { + std::fs::write(directory.path().join("instance.trigger"), "")?; + } + if read_only_copy { + std::fs::write(directory.path().join("instance.readonly"), "")?; + } + let target = TargetLauncher::binary(base.join("load_instance_program")) + .current_dir(directory.path()) + .spawn() + .await?; + if let Err(error) = wait_for_instances(directory.path(), initial_count).await { + target.terminate().await?; + return Err(error); + } + Ok((directory, target)) +} + +async fn rejects_initial_instances(target_mode: bool, copy_image: bool) -> Result<()> { + common::init(); + let (directory, target) = spawn_instances(2, copy_image, false).await?; + let runner = GhostscopeRunner::new().with_script(SCRIPT); + let runner = if target_mode { + runner.with_target(directory.path().join("libload_instance.so")) + } else { + runner.attach_to(&target) + }; + let result = runner.run().await; + target.terminate().await?; + let (code, stdout, stderr) = result?; + ensure!( + code != 0, + "ambiguous instances were accepted: {stdout}\n{stderr}" + ); + ensure!( + stderr.contains("multiple load instances are not supported"), + "{stderr}" + ); + ensure!( + !stdout.contains("INSTANCE "), + "printed a value from an ambiguous instance: {stdout}" + ); + Ok(()) +} + +#[tokio::test] +async fn test_pid_rejects_multiple_load_instances() -> Result<()> { + rejects_initial_instances(false, false).await +} + +#[tokio::test] +async fn test_target_rejects_multiple_load_instances() -> Result<()> { + rejects_initial_instances(true, false).await +} + +#[tokio::test] +async fn test_pid_rejects_different_files_with_the_same_module_cookie() -> Result<()> { + rejects_initial_instances(false, true).await +} + +#[tokio::test] +async fn test_target_rejects_different_files_with_the_same_module_cookie() -> Result<()> { + rejects_initial_instances(true, true).await +} + +#[derive(Clone, Copy)] +enum LateInstanceMode { + Pid, + Target, + FrozenTarget, +} + +async fn exercise_late_instance(mode: LateInstanceMode, read_only_copy: bool) -> Result<()> { + if !matches!(mode, LateInstanceMode::Pid) && common::skip_if_nested_t_mode_unsupported() { + return Ok(()); + } + common::init(); + let (directory, target) = spawn_instances(1, false, read_only_copy).await?; + let trigger_directory = directory.path().to_path_buf(); + let proc_pid = target.host_pid(); + let mut manager = ghostscope_process::ProcessManager::new(); + manager.ensure_prefill_pid(proc_pid)?; + ensure!(manager + .cached_offsets_with_paths_for_pid(proc_pid) + .is_some()); + let cached_range = manager + .cached_offsets_with_paths_for_pid(proc_pid) + .unwrap() + .iter() + .find(|entry| entry.module_path.ends_with("/libload_instance.so")) + .map(|entry| (entry.base, entry.size)) + .context("missing initial library offsets")?; + let runner = GhostscopeRunner::new().with_script(SCRIPT).timeout_secs(3); + let runner = match mode { + LateInstanceMode::Pid => runner.attach_to(&target), + LateInstanceMode::Target => { + runner.with_target(directory.path().join("libload_instance.so")) + } + // Leave initial offsets cached to exercise the eBPF guard independently + // of userspace observing the second load. + LateInstanceMode::FrozenTarget => runner + .with_target(directory.path().join("libload_instance.so")) + .disable_sysmon_for_target(true), + }; + let result = runner + .run_after_ready(move || async move { + tokio::time::sleep(Duration::from_millis(250)).await; + std::fs::write(trigger_directory.join("instance.trigger"), "")?; + wait_for_instances(&trigger_directory, 2).await?; + if read_only_copy { + let second_pc: u64 = + std::fs::read_to_string(trigger_directory.join("instance.second_pc"))? + .trim() + .parse()?; + ensure!( + second_pc >= cached_range.0 && second_pc - cached_range.0 < cached_range.1, + "fixture must place the second probe inside the old broad mapping range" + ); + } + let error = manager.refresh_prefill_pid(proc_pid).unwrap_err(); + ensure!( + error.is::(), + "{error}" + ); + ensure!( + manager + .cached_offsets_with_paths_for_pid(proc_pid) + .is_none(), + "refresh retained offsets for the previous single instance" + ); + Ok(()) + }) + .await; + target.terminate().await?; + let (code, stdout, stderr, ()) = result?; + ensure!(code == 0, "{stdout}\n{stderr}"); + ensure!( + stdout.contains("INSTANCE 11:11"), + "missing initial instance: {stdout}\n{stderr}" + ); + ensure!( + stdout.contains("INSTANCE 22:"), + "new instance used cached offsets: {stdout}\n{stderr}" + ); + ensure!( + !stdout.contains("INSTANCE 22:11") && !stdout.contains("INSTANCE 11:22"), + "read another instance's global: {stdout}\n{stderr}" + ); + if matches!(mode, LateInstanceMode::Target) { + ensure!( + stdout.contains("INSTANCE 11:"), + "periodic target refresh retained the old published offsets: {stdout}\n{stderr}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn test_late_load_instance_never_reads_the_other_instance() -> Result<()> { + exercise_late_instance(LateInstanceMode::Pid, false).await +} + +#[tokio::test] +async fn test_late_load_instance_rejects_stale_offsets_without_sysmon() -> Result<()> { + exercise_late_instance(LateInstanceMode::FrozenTarget, false).await +} + +#[tokio::test] +async fn test_late_load_instance_rejects_offsets_with_read_only_elf_mapping() -> Result<()> { + exercise_late_instance(LateInstanceMode::FrozenTarget, true).await +} + +#[tokio::test] +async fn test_late_load_instance_invalidates_offsets_during_target_refresh() -> Result<()> { + exercise_late_instance(LateInstanceMode::Target, false).await +} diff --git a/ghostscope-compiler/src/ebpf/helper_functions.rs b/ghostscope-compiler/src/ebpf/helper_functions.rs index 77485732..92ccabe9 100644 --- a/ghostscope-compiler/src/ebpf/helper_functions.rs +++ b/ghostscope-compiler/src/ebpf/helper_functions.rs @@ -808,6 +808,63 @@ impl<'ctx, 'dw> EbpfContext<'ctx, 'dw> { ) -> Result<(IntValue<'ctx>, IntValue<'ctx>)> { let i32_type = self.context.i32_type(); let offsets = self.lookup_proc_module_offsets_value(module_cookie, "offset")?; + let mut found = offsets.found; + if let Ok(compile_context) = self.get_compile_time_context().cloned() { + let probe_cookie = self.cookie_for_module_or_fallback(&compile_context.module_path); + let probe_offsets; + let probe = if probe_cookie == module_cookie { + &offsets + } else { + probe_offsets = + self.lookup_proc_module_offsets_value(probe_cookie, "probe_offset")?; + &probe_offsets + }; + // Uprobes attach to an inode and offset, so another dlmopen instance + // also runs this program. Userspace may not have observed the new maps + // yet. Verify the actual probe PC, not the module's broad VMA range: + // unrelated read-only mappings can enlarge that range across a new + // instance. The context retains the probe PC for cross-module reads. + let regs = self.get_pt_regs_parameter()?; + let ip = self.load_register_value(16, regs)?.into_int_value(); + let link_pc = self + .context + .i64_type() + .const_int(compile_context.pc_address, false); + let after_link_pc = self + .builder + .build_int_compare( + inkwell::IntPredicate::UGE, + ip, + link_pc, + "probe_after_link_pc", + ) + .map_err(|e| CodeGenError::LLVMError(e.to_string()))?; + let actual_bias = self + .builder + .build_int_sub(ip, link_pc, "probe_actual_bias") + .map_err(|e| CodeGenError::LLVMError(e.to_string()))?; + let matching_bias = self + .builder + .build_int_compare( + inkwell::IntPredicate::EQ, + actual_bias, + probe.text, + "probe_matching_bias", + ) + .map_err(|e| CodeGenError::LLVMError(e.to_string()))?; + let matching_instance = self + .builder + .build_and(after_link_pc, matching_bias, "probe_matching_instance") + .map_err(|e| CodeGenError::LLVMError(e.to_string()))?; + let probe_valid = self + .builder + .build_and(probe.found, matching_instance, "probe_offsets_valid") + .map_err(|e| CodeGenError::LLVMError(e.to_string()))?; + found = self + .builder + .build_and(found, probe_valid, "instance_offsets_found") + .map_err(|e| CodeGenError::LLVMError(e.to_string()))?; + } // Build a bottom-up cascade to preserve earlier choices: // tmp = (section==data) ? off_data : off_bss @@ -866,7 +923,7 @@ impl<'ctx, 'dw> EbpfContext<'ctx, 'dw> { let final_addr = self .builder .build_select::, _>( - offsets.found, + found, rt_addr.into(), link_addr.into(), "addr_or_link", @@ -874,7 +931,7 @@ impl<'ctx, 'dw> EbpfContext<'ctx, 'dw> { .map_err(|e| CodeGenError::LLVMError(e.to_string()))? .into_int_value(); - Ok((final_addr, offsets.found)) + Ok((final_addr, found)) } /// Load a register value from pt_regs pub fn load_register_value( diff --git a/ghostscope-process/src/lib.rs b/ghostscope-process/src/lib.rs index 7c6dc528..7e5f06f8 100644 --- a/ghostscope-process/src/lib.rs +++ b/ghostscope-process/src/lib.rs @@ -5,8 +5,8 @@ pub mod pinned_bpf_maps; pub mod proc_maps; pub mod target_arch; pub use offsets::{ - PidOffsetsEntry, ProcessManager, ProcessManagerSnapshot, ProcessManagerSnapshotReader, - SectionOffsets, + MultipleLoadInstances, PidOffsetsEntry, ProcessManager, ProcessManagerSnapshot, + ProcessManagerSnapshotReader, SectionOffsets, }; pub use pid::{ build_runtime_pid_plan, detect_runtime_environment, host_pid_for_proc_pid, diff --git a/ghostscope-process/src/offsets.rs b/ghostscope-process/src/offsets.rs index b5f21482..d6dfb309 100644 --- a/ghostscope-process/src/offsets.rs +++ b/ghostscope-process/src/offsets.rs @@ -12,6 +12,26 @@ use std::os::unix::fs::MetadataExt; use std::path::Path; use std::sync::{Arc, RwLock}; +/// The current `(pid, module cookie)` map cannot identify separate loads of one ELF. +#[derive(Debug)] +pub struct MultipleLoadInstances { + pub pid: u32, + pub module_path: String, + pub count: usize, +} + +impl std::fmt::Display for MultipleLoadInstances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "multiple load instances are not supported: PID {} has {} incompatible load biases for {}", + self.pid, self.count, self.module_path + ) + } +} + +impl std::error::Error for MultipleLoadInstances {} + /// Per-module section offsets (runtime bias) computed from /proc/PID/maps #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct SectionOffsets { @@ -126,11 +146,14 @@ struct CachedEntry { offsets: SectionOffsets, base: u64, size: u64, + /// Full executable mapping identity for the last successful cookie check. + executable_maps: Vec, } #[derive(Debug, Clone, Default)] struct ModuleMapSummary { candidates: Vec<(u64, u64)>, + executable_candidates: Vec<(u64, u64)>, min_start: Option, max_end: Option, } @@ -140,6 +163,9 @@ impl ModuleMapSummary { self.min_start = Some(self.min_start.map_or(entry.start, |v| v.min(entry.start))); self.max_end = Some(self.max_end.map_or(entry.end, |v| v.max(entry.end))); self.candidates.push((entry.offset, entry.start)); + if entry.executable() { + self.executable_candidates.push((entry.offset, entry.start)); + } } fn base(&self) -> u64 { @@ -153,6 +179,8 @@ impl ModuleMapSummary { fn merge(&mut self, other: &Self) { self.candidates.extend(other.candidates.iter().copied()); + self.executable_candidates + .extend(other.executable_candidates.iter().copied()); if let Some(start) = other.min_start { self.min_start = Some(self.min_start.map_or(start, |v| v.min(start))); } @@ -350,6 +378,14 @@ impl ProcessManager { } pub fn ensure_prefill_module(&mut self, module_path: &str) -> Result { + self.prefill_module(module_path, |error| Err(error.into())) + } + + fn prefill_module( + &mut self, + module_path: &str, + mut reject_pid: impl FnMut(MultipleLoadInstances) -> Result<()>, + ) -> Result { if self.prefilled_modules.contains(module_path) { return Ok(0); } @@ -408,8 +444,8 @@ impl ProcessManager { } } } - let mut cached: Vec = Vec::new(); - let mut new_count = 0usize; + let mut cached = Vec::new(); + let mut rejected_any = false; // Intentionally keep PID list silent to avoid noisy logs in normal runs for pid in pids { match self.compute_section_offsets_for_process_with_retry( @@ -418,15 +454,18 @@ impl ProcessManager { 3, std::time::Duration::from_millis(75), ) { - Ok((cookie, offsets, base, size)) => { - cached.push(CachedEntry { - pid, - cookie, - offsets, - base, - size, - }); - new_count += 1; + Ok(entry) => cached.push(entry), + Err(e) if e.is::() => { + self.forget_pid(pid); + self.prefilled_modules.remove(module_path); + rejected_any = true; + if let Err(error) = reject_pid( + e.downcast::() + .expect("error type matched above"), + ) { + self.module_cache.remove(module_path); + return Err(error); + } } Err(e) => tracing::debug!( "ProcessManager: skip pid {} for module {} (offsets failed: {})", @@ -436,8 +475,11 @@ impl ProcessManager { ), } } + let new_count = cached.len(); self.module_cache.insert(module_path.to_string(), cached); - self.prefilled_modules.insert(module_path.to_string()); + if !rejected_any { + self.prefilled_modules.insert(module_path.to_string()); + } Ok(new_count) } @@ -461,13 +503,47 @@ impl ProcessManager { self.ensure_prefill_module(module_path) } + /// Reconcile supported PIDs while letting the publisher invalidate rejected ones. + /// A partial scan never satisfies a later strict setup prefill. + pub(crate) fn refresh_prefill_module_with_rejections( + &mut self, + module_path: &str, + reject_pid: impl FnMut(MultipleLoadInstances) -> Result<()>, + ) -> Result { + self.prefilled_modules.remove(module_path); + self.prefill_module(module_path, reject_pid) + } + pub fn ensure_prefill_pid(&mut self, pid: u32) -> Result { if self.prefilled_pids.contains(&pid) { return Ok(0); } let maps = read_proc_maps(pid)?; + let list = match self.collect_pid_offsets(pid, &maps) { + Ok(list) => list, + Err(error) => { + if error.is::() { + self.forget_pid(pid); + } + return Err(error); + } + }; + self.pid_cache.insert(pid, list); + self.prefilled_pids.insert(pid); + self.publish_render_pid_snapshot(pid); + Ok(self.pid_cache.get(&pid).map(|v| v.len()).unwrap_or(0)) + } + + /// Validate one coherent maps snapshot without publishing it as PID prefill. + /// Target-mode cookie checks reuse this path, but must leave the initial full + /// PID publication to sysmon rather than marking its work already complete. + fn collect_pid_offsets( + &self, + pid: u32, + maps: &[OwnedProcMapEntry], + ) -> Result> { let mut module_summaries: BTreeMap = BTreeMap::new(); - for entry in &maps { + for entry in maps { let Some(path) = entry.path() else { continue; }; @@ -492,21 +568,44 @@ impl ProcessManager { ); continue; }; + if summary.executable_candidates.is_empty() { + // Reading an ELF through mmap does not load it as a runtime module. + continue; + } match self.compute_section_offsets_from_candidates( pid, &module_path, &summary.candidates, + &summary.executable_candidates, summary.base(), summary.size(), ) { - Ok((cookie, off, base, size)) => list.push(PidOffsetsEntry { - module_path, - cookie, - offsets: off, - base, - size, - }), + Ok((cookie, off, base, size)) => { + // Cookies prefer Build ID. Hard links and separate copies can + // therefore collide even when proc maps lists different paths. + if list + .iter() + .any(|entry| entry.cookie == cookie && entry.offsets.text != off.text) + { + return Err(MultipleLoadInstances { + pid, + module_path, + count: 2, + } + .into()); + } + list.push(PidOffsetsEntry { + module_path, + cookie, + offsets: off, + base, + size, + }); + } Err(e) => { + if e.is::() { + return Err(e); + } tracing::debug!( "ProcessManager: skip module {} for pid {}: {}", module_path, @@ -516,10 +615,7 @@ impl ProcessManager { } } } - self.pid_cache.insert(pid, list); - self.prefilled_pids.insert(pid); - self.publish_render_pid_snapshot(pid); - Ok(self.pid_cache.get(&pid).map(|v| v.len()).unwrap_or(0)) + Ok(list) } /// Force-refresh per-PID cache (used when exec-time prefill raced with module mapping). @@ -591,13 +687,16 @@ impl ProcessManager { pid, &module_path, &summary.candidates, + &summary.executable_candidates, summary.base(), summary.size(), ); let (cookie, offsets, base, size) = match computed { Ok(computed) => computed, Err(error) => { - if removed_stale_entry { + if error.is::() { + self.forget_pid(pid); + } else if removed_stale_entry { self.publish_render_pid_snapshot(pid); } return Err(error); @@ -610,6 +709,19 @@ impl ProcessManager { base, size, }; + if self.pid_cache.get(&pid).is_some_and(|entries| { + entries + .iter() + .any(|existing| existing.cookie == cookie && existing.offsets.text != offsets.text) + }) { + self.forget_pid(pid); + return Err(MultipleLoadInstances { + pid, + module_path: entry.module_path, + count: 2, + } + .into()); + } self.upsert_pid_offset(pid, entry.clone()); Ok(Some(entry)) } @@ -642,24 +754,62 @@ impl ProcessManager { &self, pid: u32, module_path: &str, - ) -> Result<(u64, SectionOffsets, u64, u64)> { + ) -> Result { let module_path = normalize_mapped_module_path(module_path); let mut candidates: Vec<(u64, u64)> = Vec::new(); + let mut executable_candidates = Vec::new(); + let mut maps = Vec::new(); let mut min_start: Option = None; let mut max_end: Option = None; let target = ModuleIdentity::from_path(Path::new(module_path)); visit_proc_maps(pid, |entry| { + maps.push(OwnedProcMapEntry::from(entry)); if !target.matches(&entry) { return ControlFlow::Continue(()); } min_start = Some(min_start.map_or(entry.start, |v| v.min(entry.start))); max_end = Some(max_end.map_or(entry.end, |v| v.max(entry.end))); candidates.push((entry.offset, entry.start)); + if entry.executable() { + executable_candidates.push((entry.offset, entry.start)); + } ControlFlow::Continue(()) })?; + let executable_maps = maps + .iter() + .filter(|entry| entry.executable() && entry.path().is_some()) + .cloned() + .collect::>(); + let cookie_check_is_current = self + .module_cache + .get(module_path) + .and_then(|entries| entries.iter().find(|entry| entry.pid == pid)) + .is_some_and(|entry| entry.executable_maps == executable_maps); + if !cookie_check_is_current { + // An inode-only target scan misses another file with the same Build + // ID. Check every loaded cookie using the PID-mode identity rules. + // Reuse successful checks while the executable mappings are stable + // so periodic target polling does not reparse every ELF every time. + self.collect_pid_offsets(pid, &maps)?; + } let base = min_start.unwrap_or(0); let size = max_end.unwrap_or(base).saturating_sub(base); - self.compute_section_offsets_from_candidates(pid, module_path, &candidates, base, size) + let (cookie, offsets, base, size) = self.compute_section_offsets_from_candidates( + pid, + module_path, + &candidates, + &executable_candidates, + base, + size, + )?; + Ok(CachedEntry { + pid, + cookie, + offsets, + base, + size, + executable_maps, + }) } fn compute_section_offsets_from_candidates( @@ -667,12 +817,56 @@ impl ProcessManager { pid: u32, module_path: &str, candidates: &[(u64, u64)], + executable_candidates: &[(u64, u64)], base: u64, size: u64, ) -> Result<(u64, SectionOffsets, u64, u64)> { let probe = ModuleProbe::open(module_path)?; let obj = probe.object()?; let page_mask: u64 = !0xfffu64; + // Only executable mappings establish a loaded image. An ordinary read-only + // mmap of the ELF (e.g. by a symbol reader) must not create another instance. + // Intersect across executable segments: two segments can share a file page, + // so a union would mistake cross-segment matches for additional instances. + let mut executable_biases: Option> = None; + let mut observed_executable_biases = BTreeSet::new(); + for seg in obj.segments().filter(|seg| { + matches!(seg.flags(), object::SegmentFlags::Elf { p_flags } + if p_flags & object::elf::PF_X != 0) + }) { + let key = seg.file_range().0 & page_mask; + let biases: BTreeSet<_> = executable_candidates + .iter() + .filter(|(offset, _)| offset & page_mask == key) + .filter_map(|(_, start)| start.checked_sub(seg.address() & page_mask)) + .collect(); + if biases.is_empty() { + continue; + } + observed_executable_biases.extend(biases.iter().copied()); + executable_biases = Some(match executable_biases { + None => biases, + Some(previous) => previous.intersection(&biases).copied().collect(), + }); + } + if let Some(biases) = &executable_biases { + if biases.len() != 1 { + return Err(MultipleLoadInstances { + pid, + module_path: module_path.to_string(), + // An empty intersection is contradictory evidence, not a + // reason to fall back to the first readable mapping. It can + // occur when mprotect leaves different executable segments + // active in separate instances of the same ELF. + count: if biases.is_empty() { + observed_executable_biases.len() + } else { + biases.len() + }, + } + .into()); + } + } let mut seg_bias: Vec<(u64, u64, u64)> = Vec::new(); for seg in obj.segments() { let (file_off, _sz) = seg.file_range(); @@ -733,8 +927,10 @@ impl ProcessManager { // G_COUNTER). To rebase it we only need the ASLR bias `module_base`, not per-section // runtime starts. Derive that bias from whichever segment we could match, then store it for // all four slots so the eBPF helper can simply do `link_addr + bias`. - let module_base = text_addr - .and_then(find_bias_for) + let module_base = executable_biases + .as_ref() + .and_then(|biases| biases.first().copied()) + .or_else(|| text_addr.and_then(find_bias_for)) .or_else(|| rodata_addr.and_then(find_bias_for)) .or_else(|| data_addr.and_then(find_bias_for)) .or_else(|| bss_addr.and_then(find_bias_for)) @@ -797,11 +993,12 @@ impl ProcessManager { module_path: &str, attempts: usize, backoff: std::time::Duration, - ) -> Result<(u64, SectionOffsets, u64, u64)> { + ) -> Result { let mut last_err: Option = None; for i in 0..attempts { match self.compute_section_offsets_for_process(pid, module_path) { Ok(v) => return Ok(v), + Err(e) if e.is::() => return Err(e), Err(e) => { last_err = Some(e); if i + 1 < attempts { @@ -964,15 +1161,24 @@ mod tests { /// Build an ELF with a controlled PT_LOAD layout, independent of the host linker. fn write_load_bias_fixture(path: &Path, file_offset: u64, vaddr: u64) { + write_load_bias_segments_fixture(path, &[(file_offset, vaddr)]); + } + + fn write_load_bias_segments_fixture(path: &Path, segments: &[(u64, u64)]) { use object::write::elf::{FileHeader, ProgramHeader, SectionHeader, Writer}; use object::{elf, Endianness}; + let (file_offset, vaddr) = segments[0]; + let file_end = segments + .iter() + .map(|(offset, _)| offset + 16) + .max() + .unwrap(); let mut bytes = Vec::new(); let mut writer = Writer::new(Endianness::Little, true, &mut bytes); writer.reserve_file_header(); - writer.reserve_program_headers(1); - writer.reserve_until(file_offset as usize); - writer.reserve(16, 1); + writer.reserve_program_headers(segments.len() as u32); + writer.reserve_until(file_end as usize); writer.reserve_section_index(); let text_name = writer.add_section_name(b".text"); writer.reserve_shstrtab_section_index(); @@ -989,18 +1195,19 @@ mod tests { }) .unwrap(); writer.write_align_program_headers(); - writer.write_program_header(&ProgramHeader { - p_type: elf::PT_LOAD, - p_flags: elf::PF_R | elf::PF_X, - p_offset: file_offset, - p_vaddr: vaddr, - p_paddr: vaddr, - p_filesz: 16, - p_memsz: 16, - p_align: 0x1000, - }); - writer.pad_until(file_offset as usize); - writer.write(&[0; 16]); + for &(offset, address) in segments { + writer.write_program_header(&ProgramHeader { + p_type: elf::PT_LOAD, + p_flags: elf::PF_R | elf::PF_X, + p_offset: offset, + p_vaddr: address, + p_paddr: address, + p_filesz: 16, + p_memsz: 16, + p_align: 0x1000, + }); + } + writer.pad_until(file_end as usize); writer.write_shstrtab(); writer.write_null_section_header(); writer.write_section_header(&SectionHeader { @@ -1032,6 +1239,7 @@ mod tests { 42, path.to_str().unwrap(), &[(0x27000, expected_bias + 0x28000)], + &[(0x27000, expected_bias + 0x28000)], expected_bias, 0x30000, ) @@ -1058,16 +1266,111 @@ mod tests { 42, path.to_str().unwrap(), &[(0x1000, 0x401000)], + &[(0x1000, 0x401000)], 0x400000, 0x2000, ) .unwrap(); assert_eq!(offsets, SectionOffsets::default()); assert!(manager - .compute_section_offsets_from_candidates(42, path.to_str().unwrap(), &[], 0, 0) + .compute_section_offsets_from_candidates(42, path.to_str().unwrap(), &[], &[], 0, 0) .is_err()); } + #[test] + fn executable_load_instances_are_rejected_but_read_only_copies_are_not() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("multiple.elf"); + write_load_bias_fixture(&path, 0x1000, 0x1000); + let manager = ProcessManager::new(); + let candidates = [(0x1000, 0x501000), (0x1000, 0x701000)]; + let error = manager + .compute_section_offsets_from_candidates( + 42, + path.to_str().unwrap(), + &candidates, + &candidates, + 0x500000, + 0x202000, + ) + .unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().count, + 2 + ); + let (_, offsets, _, _) = manager + .compute_section_offsets_from_candidates( + 42, + path.to_str().unwrap(), + &candidates, + &candidates[1..], + 0x500000, + 0x202000, + ) + .unwrap(); + assert_eq!(offsets.text, 0x700000); + } + + #[test] + fn executable_segments_sharing_a_file_page_keep_the_unique_load_bias() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("shared-page.elf"); + write_load_bias_segments_fixture(&path, &[(0x1000, 0x1000), (0x1800, 0x2800)]); + let expected_bias = 0x500000; + let candidates = [ + (0x1000, expected_bias + 0x1000), + (0x1000, expected_bias + 0x2000), + ]; + let (_, offsets, _, _) = ProcessManager::new() + .compute_section_offsets_from_candidates( + 42, + path.to_str().unwrap(), + &candidates, + &candidates, + expected_bias, + 0x3000, + ) + .unwrap(); + assert_eq!(offsets.text, expected_bias); + } + + #[test] + fn contradictory_executable_segments_do_not_fall_back_to_one_instance() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("contradictory.elf"); + write_load_bias_segments_fixture(&path, &[(0x1000, 0x1000), (0x2000, 0x3000)]); + let manager = ProcessManager::new(); + let first_bias = 0x500000; + for second_bias in [first_bias, 0x700000] { + // mprotect can leave only the first executable segment in one + // instance and only the second executable segment in another. + let candidates = [ + (0x1000, first_bias + 0x1000), + (0x2000, second_bias + 0x3000), + ]; + let result = manager.compute_section_offsets_from_candidates( + 42, + path.to_str().unwrap(), + &candidates, + &candidates, + first_bias, + second_bias + 0x4000 - first_bias, + ); + if second_bias == first_bias { + assert_eq!(result.unwrap().1.text, first_bias); + } else { + assert_eq!( + result + .unwrap_err() + .downcast_ref::() + .unwrap() + .count, + 2 + ); + } + } + } + #[test] fn detached_discovery_cannot_publish_or_overwrite_newer_pid_mappings() { let entry = |cookie| PidOffsetsEntry { @@ -1167,6 +1470,7 @@ mod tests { offsets: SectionOffsets::default(), base: 0, size: 0, + executable_maps: Vec::new(), }, CachedEntry { pid: 7, @@ -1174,6 +1478,7 @@ mod tests { offsets: SectionOffsets::default(), base: 0, size: 0, + executable_maps: Vec::new(), }, ], ); diff --git a/ghostscope-process/src/sysmon/offset_refresh.rs b/ghostscope-process/src/sysmon/offset_refresh.rs index 4b7a55a5..7ee525ae 100644 --- a/ghostscope-process/src/sysmon/offset_refresh.rs +++ b/ghostscope-process/src/sysmon/offset_refresh.rs @@ -115,6 +115,44 @@ pub(super) fn pid_alive(pid: u32) -> bool { std::path::Path::new(&format!("/proc/{pid}")).exists() } +fn invalidate_ambiguous_offsets( + result: anyhow::Result, + runtime_pids: &[u32], +) -> anyhow::Result { + match result { + Err(error) if error.is::() => { + // A failed refresh must also remove the previously published single + // instance. Otherwise future probes continue reading its stale offsets. + if let Err(purge_error) = purge_offsets_for_runtime_pid_keys(runtime_pids) { + // Keep the typed rejection so callers cannot reinterpret a purge + // failure as a recoverable lookup miss and republish offsets. + return Err(error.context(format!( + "failed to invalidate ambiguous process offsets: {purge_error}" + ))); + } + Err(error) + } + result => result, + } +} + +fn refresh_module_offsets( + manager: &mut ProcessManager, + module_path: &str, +) -> anyhow::Result { + manager.refresh_prefill_module_with_rejections(module_path, |error| { + let event_pid = resolve_event_pid_for_proc(error.pid); + let runtime_pids = runtime_pid_keys_for_proc_event(error.pid, event_pid, []); + if let Err(purge_error) = purge_offsets_for_runtime_pid_keys(&runtime_pids) { + return Err(anyhow::Error::new(error).context(format!( + "failed to invalidate ambiguous process offsets: {purge_error}" + ))); + } + tracing::warn!("Sysmon: invalidated offsets for rejected process: {error}"); + Ok(()) + }) +} + pub(super) fn filter_entries_for_target<'a>( entries: &'a [PidOffsetsEntry], target: Option<&Path>, @@ -192,12 +230,17 @@ pub(super) fn write_offsets_for_pid( ); return Ok(false); } - let prefilled = match if force_refresh { - guard.refresh_prefill_pid(proc_pid) - } else { - guard.ensure_prefill_pid(proc_pid) - } { + let prefill_result = invalidate_ambiguous_offsets( + if force_refresh { + guard.refresh_prefill_pid(proc_pid) + } else { + guard.ensure_prefill_pid(proc_pid) + }, + &runtime_pids, + ); + let prefilled = match prefill_result { Ok(v) => v, + Err(e) if e.is::() => return Err(e), Err(e) => { // In private PID namespaces, sysmon event PID may be in the initial namespace // and not resolvable via /proc/. Fall back to module-wide refresh. @@ -210,7 +253,7 @@ pub(super) fn write_offsets_for_pid( e, module_path ); - let refreshed = guard.refresh_prefill_module(&module_path)?; + let refreshed = refresh_module_offsets(&mut guard, &module_path)?; if refreshed > 0 { tracing::info!( "Sysmon: module refresh cached {} pid(s) for {}", @@ -261,7 +304,8 @@ pub(super) fn write_offsets_for_pid( let mut target_match_count = filter_entries_for_target(&entries, target).len(); if target_match_count == 0 && target.is_some() { - let refreshed = guard.refresh_prefill_pid(proc_pid)?; + let refreshed = + invalidate_ambiguous_offsets(guard.refresh_prefill_pid(proc_pid), &runtime_pids)?; if refreshed > 0 { tracing::debug!( "Sysmon: refreshed {} cached entries for event pid {} (proc pid {})", @@ -353,7 +397,8 @@ pub(super) fn prefill_full_offsets_for_pid_if_new( let Ok(mut guard) = mgr.lock() else { return Ok(false); }; - let prefilled = guard.ensure_prefill_pid(proc_pid)?; + let prefilled = + invalidate_ambiguous_offsets(guard.ensure_prefill_pid(proc_pid), &runtime_pids)?; if prefilled == 0 { return Ok(false); } @@ -435,7 +480,7 @@ pub(super) fn refresh_full_offsets_for_pid( let Ok(mut guard) = mgr.lock() else { return Ok(false); }; - guard.refresh_prefill_pid(proc_pid)?; + invalidate_ambiguous_offsets(guard.refresh_prefill_pid(proc_pid), &runtime_pids)?; let Some(entries) = guard.cached_offsets_with_paths_for_pid(proc_pid) else { return Ok(false); }; @@ -516,7 +561,7 @@ pub(super) fn refresh_target_module_offsets( let mut by_pid: HashMap> = HashMap::new(); let mut target_pids: BTreeSet = BTreeSet::new(); if let Ok(mut guard) = mgr.lock() { - if let Err(e) = guard.refresh_prefill_module(&module_path) { + if let Err(e) = refresh_module_offsets(&mut guard, &module_path) { tracing::debug!( "Sysmon: periodic module refresh failed for {}: {}", module_path, diff --git a/ghostscope/src/script/attach.rs b/ghostscope/src/script/attach.rs index 9f28f6d3..5debaaaa 100644 --- a/ghostscope/src/script/attach.rs +++ b/ghostscope/src/script/attach.rs @@ -188,9 +188,13 @@ pub(super) async fn create_and_attach_loader( .coordinator .lock() .expect("coordinator mutex poisoned"); - let prefilled = coordinator - .ensure_prefill_module(&config.binary_path) - .unwrap_or(0); + let prefilled = match coordinator.ensure_prefill_module(&config.binary_path) { + Ok(count) => count, + Err(error) if error.is::() => { + return Err(error); + } + Err(_) => 0, + }; let entries = coordinator.cached_offsets_for_module(&config.binary_path); (prefilled, entries) }; diff --git a/ghostscope/src/script/cli.rs b/ghostscope/src/script/cli.rs index 21b2e026..840b7335 100644 --- a/ghostscope/src/script/cli.rs +++ b/ghostscope/src/script/cli.rs @@ -75,7 +75,7 @@ pub async fn compile_and_load_script_for_cli( prepare_runtime_modules_before_compile(script, session, &mut compile_options).await?; let compilation_result = compile_script_for_cli(script, session, &compile_options)?; - ensure_prefill_for_session_pid(session); + ensure_prefill_for_session_pid(session)?; let ghostscope_compiler::CompilationResult { uprobe_configs, diff --git a/ghostscope/src/script/runtime_maps.rs b/ghostscope/src/script/runtime_maps.rs index f8c3acc2..92130156 100644 --- a/ghostscope/src/script/runtime_maps.rs +++ b/ghostscope/src/script/runtime_maps.rs @@ -57,7 +57,7 @@ pub(super) fn apply_pid_alias_for_session( } } -pub(super) fn ensure_prefill_for_session_pid(session: &GhostSession) { +pub(super) fn ensure_prefill_for_session_pid(session: &GhostSession) -> anyhow::Result<()> { if let Some(proc_pid) = session.proc_pid() { let result = { let mut coordinator = session @@ -71,12 +71,14 @@ pub(super) fn ensure_prefill_for_session_pid(session: &GhostSession) { "Coordinator cached {} module offset entries for PID {}", count, proc_pid ), + Err(e) if e.is::() => return Err(e), Err(e) => warn!( "Failed to compute section offsets via coordinator: {} (globals may show OffsetsUnavailable)", e ), } } + Ok(()) } pub(super) fn apply_cached_offsets_for_session_pid(session: &GhostSession) { diff --git a/ghostscope/src/script/tui.rs b/ghostscope/src/script/tui.rs index d30f06fd..232cb794 100644 --- a/ghostscope/src/script/tui.rs +++ b/ghostscope/src/script/tui.rs @@ -52,7 +52,7 @@ pub async fn compile_and_load_script_for_tui( success_count, failed_count ); - ensure_prefill_for_session_pid(session); + ensure_prefill_for_session_pid(session)?; if !compilation_result.uprobe_configs.is_empty() { let uprobe_configs = compilation_result.uprobe_configs;