diff --git a/docs/input-commands.md b/docs/input-commands.md index 49aaea17..104924e6 100644 --- a/docs/input-commands.md +++ b/docs/input-commands.md @@ -23,6 +23,7 @@ t [index] # Short form **Parameters:** - ``: Can be: - Function name: `function_name` + - Function-name prefix wildcard: `function_prefix*` (one trailing `*` only) - File and line: `file:line` where file can be: - Full path: `/path/to/file.c:42` - Relative path: `src/file.c:42` @@ -34,6 +35,7 @@ t [index] # Short form - `-t -p `: defaults to ``; `-p` only limits events to that PID - This is not a raw ELF file offset or a runtime ASLR-adjusted address from `/proc//maps`. - `module_suffix:0xADDR`: Address in a specific module. The module part supports full path or unique suffix matching; ambiguous suffixes will be reported with candidates. In `-t -p` sessions, the module must match the `-t` target. +- `[index]`: Optional 1-based concrete-address selector for non-wildcard targets. Wildcard targets always trace every match and do not accept an index. When `-t` and `-p` are both present, function, source-line, and address trace targets are all resolved inside the `-t` module. `-p` only limits runtime events to that process. @@ -42,6 +44,7 @@ When `-t` and `-p` are both present, function, source-line, and address trace ta trace main # Trace main function trace main 2 # Trace only the 2nd address of 'main' (see 'info function main') trace calculate_something # Trace specific function +trace get_* # Trace every function whose name starts with get_ trace /home/user/src/sample.c:42 # Full path trace /home/user/src/sample.c:42 1 # Trace only the 1st address for that line trace src/sample.c:42 # Relative path @@ -102,6 +105,13 @@ trace main # Your previous script is restored! - Inline: selects the inline instance start (low_pc semantics of the inline DIE); this is an entry-like point and may not align to a statement boundary. Use a line target if you need precise statement alignment. - Multiple inline instances: if a function is inlined at multiple call sites, you’ll see one address per instance (plus one for the non-inline definition, if present). +- Function-name wildcard target (`trace { ... }`): + - Performs literal prefix matching; only one trailing `*` is supported, so it is not a general glob or regular expression. + - Matches attachable names from DWARF/GDB function indexes and ELF text symbols. + - Expands to concrete function addresses in stable function-name/module/address order and deduplicates aliases at the same module address. + - A wildcard is limited to 64 concrete addresses. Use a narrower prefix or an exact function name if it exceeds the limit. + - PID-only mode searches all modules known at setup. With `-t`, matching is restricted to that target module. + - Source line target (`trace { ... }`): - Resolves to the statement boundary on that line for each occurrence (one per inline instance across callers), i.e., statement-level semantics. - Typically yields one address per instance where that source line is active. @@ -181,7 +191,7 @@ s t [file] # Short form **Behavior:** - Saves each trace as a `trace { ... }` block with metadata. -- If a trace was created with `trace [index]`, the selected address index is preserved in the save file and restored on load. +- If a non-wildcard trace was created with `trace [index]`, the selected address index is preserved in the save file and restored on load. **Examples:** ``` @@ -279,7 +289,7 @@ s # Short form (but not "s t") **Behavior:** - Loads all `trace { ... }` blocks in the file. -- If a block includes an address index (saved from a prior session), only that indexed address is reattached for the target. +- If a non-wildcard block includes an address index (saved from a prior session), only that indexed address is reattached for the target. **Examples:** ``` diff --git a/docs/scripting.md b/docs/scripting.md index 532e7284..d6059cbd 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -70,6 +70,7 @@ trace { ### Trace Patterns #### Function Name + ```ghostscope trace main { print "Main called"; @@ -80,7 +81,29 @@ trace calculate_something { } ``` +#### Function-Name Prefix Wildcard + +```ghostscope +trace get_* { + print "A get_ function was called"; +} +``` + +The wildcard syntax is a literal function-name prefix followed by one trailing +`*`. For example, `get_*` matches `get_random_value` and +`get_string_length`; it is not a general glob or regular expression. GhostScope +creates one probe for every concrete resolved address, ordered by function +name, module path, and address. Aliases at the same module address are attached +only once. Candidate names come from both DWARF/GDB function indexes and ELF +text symbols, so prefix matching remains available for symbol-only modules. + +A wildcard can expand to at most 64 concrete addresses. Narrow the prefix or +use an exact function name if it exceeds that safety limit. In PID-only mode, +matching covers all modules known when tracing starts; add `-t ` to +scope a broad prefix to one module. + #### Source Line + ```ghostscope // Trace a specific file and line trace sample.c:42 { @@ -94,6 +117,7 @@ trace /home/user/project/src/utils.c:100 { ``` #### Address + ```ghostscope // Module-relative virtual address (DWARF/symbol PC) trace 0x401234 { @@ -107,7 +131,7 @@ trace libc.so.6:0x1234 { ``` Notes: -- When `-t` and `-p` are both present, all trace patterns (function, source line, bare address, and module-qualified address) are resolved inside the `-t` target. `-p` only limits runtime events to that process. +- When `-t` and `-p` are both present, all trace patterns (function, function-name wildcard, source line, bare address, and module-qualified address) are resolved inside the `-t` target. `-p` only limits runtime events to that process. - For `0xADDR`, the default module depends on startup mode: `-t ` uses ``; `-p ` uses the main executable. If `-t` and `-p` are both present, `-t` wins and `-p` only limits runtime events to that PID. - `module_suffix:0xADDR` allows selecting a module by full path or unique suffix; ambiguous suffixes will list candidates. In `-t -p` sessions, the module must match the `-t` target. - Address trace targets always use the module's DWARF/symbol virtual address. Do not pass a raw ELF file offset or a runtime ASLR-adjusted address from `/proc//maps`; GhostScope converts the virtual address to the uprobe file offset internally. diff --git a/docs/zh/input-commands.md b/docs/zh/input-commands.md index 1cc1dc94..e4d2fbbc 100644 --- a/docs/zh/input-commands.md +++ b/docs/zh/input-commands.md @@ -21,6 +21,7 @@ t [index] # 缩写形式 **参数:** - ``: 可以是: - 函数名:`function_name` + - 函数名前缀通配符:`function_prefix*`(只支持一个末尾 `*`) - 文件和行号:`file:line`,其中文件可以是: - 完整路径:`/path/to/file.c:42` - 相对路径:`src/file.c:42` @@ -32,6 +33,7 @@ t [index] # 缩写形式 - `-t -p `:默认模块为 ``;`-p` 只把事件限制到该 PID - 这里不是原始 ELF 文件偏移,也不是 `/proc//maps` 中 ASLR 后的运行时地址。 - `模块后缀:0xADDR`:指定模块 + 地址。模块部分支持“全路径”或“唯一后缀”匹配;若后缀不唯一,会报歧义并列出候选。在 `-t -p` 会话中,该模块必须匹配 `-t` 目标。 +- `[index]`:非通配符目标可选的具体地址序号,从 1 开始。通配符目标始终追踪全部匹配项,不接受序号。 当 `-t` 和 `-p` 同时出现时,函数、源码行和地址 trace 目标都在 `-t` 模块内解析。`-p` 只把运行时事件限制到该进程。 @@ -40,6 +42,7 @@ t [index] # 缩写形式 trace main # 追踪 main 函数 trace main 2 # 只追踪 main 的第 2 个地址(参考 info function main 的编号) trace calculate_something # 追踪特定函数 +trace get_* # 追踪所有名称以 get_ 开头的函数 trace /home/user/src/sample.c:42 # 完整路径 trace /home/user/src/sample.c:42 1 # 只追踪该行的第 1 个地址 trace src/sample.c:42 # 相对路径 @@ -100,6 +103,13 @@ trace main # 你之前的脚本会被恢复! - 内联:选择“内联实例的起始位置”(inline DIE 的 low_pc 语义),这是“入口类”的落点,未必对齐语句边界;若需要精确落在语句起始,建议使用行号目标。 - 多个内联实例:若函数在多个调用点被内联,会为每个内联实例各返回一个地址(若另有非内联定义,另加 1 个)。 +- 函数名通配符目标(`trace { ... }`): + - 按字面前缀匹配;只支持一个末尾 `*`,不是通用 glob 或正则表达式。 + - 匹配 DWARF/GDB 函数索引和 ELF 文本符号中的可挂载名称。 + - 按函数名、模块路径、地址的稳定顺序展开为具体函数地址,并对同一模块地址的别名去重。 + - 一个通配符最多展开 64 个具体地址。超过上限时请缩小前缀或使用完整函数名。 + - 仅使用 `-p` 时会搜索 setup 阶段已知的所有模块;使用 `-t` 时,匹配范围仅限该目标模块。 + - 源码行号目标(`trace { ... }`): - 对应“语句级”语义:解析到该行的语句边界(is_stmt)并在每个出现处各给一个地址(每个内联实例各一处)。 - 通常会在所有出现该行的实例处各有 1 个地址。 @@ -179,7 +189,7 @@ s t [file] # 缩写形式 **行为:** - 以 `trace { ... }` 区块形式保存每条追踪及元信息。 -- 若追踪是通过 `trace [index]` 创建的,会将所选“地址序号”一并保存,并在加载时恢复该序号。 +- 若非通配符追踪是通过 `trace [index]` 创建的,会将所选“地址序号”一并保存,并在加载时恢复该序号。 **示例:** ``` @@ -277,7 +287,7 @@ s # 缩写形式(但不包括 "s t") **行为:** - 加载文件中的所有 `trace { ... }` 区块。 -- 若区块内包含保存的“地址序号”,则仅为该目标重新挂载对应序号的地址。 +- 若非通配符区块内包含保存的“地址序号”,则仅为该目标重新挂载对应序号的地址。 **示例:** ``` diff --git a/docs/zh/scripting.md b/docs/zh/scripting.md index 8092421a..373b7fda 100644 --- a/docs/zh/scripting.md +++ b/docs/zh/scripting.md @@ -64,6 +64,7 @@ trace <模式> { ### 追踪模式 #### 函数名 + ```ghostscope trace main { print "Main 函数被调用"; @@ -74,7 +75,26 @@ trace calculate_something { } ``` +#### 函数名前缀通配符 + +```ghostscope +trace get_* { + print "命中了 get_ 前缀函数"; +} +``` + +通配符语法是字面函数名前缀加一个末尾 `*`。例如,`get_*` 会匹配 +`get_random_value` 和 `get_string_length`;它不是通用 glob 或正则表达式。 +GhostScope 会为每个解析出的具体地址创建一个探针,顺序固定为函数名、模块路径、 +地址;同一模块地址的别名只会 attach 一次。候选函数名同时来自 DWARF/GDB 函数 +索引和 ELF 文本符号,因此只有符号表、没有 DWARF 的模块也支持前缀匹配。 + +一个通配符最多展开为 64 个具体地址;超过安全上限时请缩小前缀或使用完整函数名。 +仅使用 `-p` 时会匹配开始追踪时已知的所有模块;可添加 `-t `,把较宽的 +前缀限定到一个模块。 + #### 源代码行 + ```ghostscope // 追踪特定文件和行号 trace sample.c:42 { @@ -88,6 +108,7 @@ trace /home/user/project/src/utils.c:100 { ``` #### 地址 + ```ghostscope // 按模块相对虚拟地址(DWARF/符号表 PC)追踪 trace 0x401234 { @@ -101,7 +122,7 @@ trace libc.so.6:0x1234 { ``` 说明: -- 当 `-t` 和 `-p` 同时出现时,所有 trace pattern(函数、源码行、裸地址、模块限定地址)都在 `-t` 目标内解析。`-p` 只把运行时事件限制到该进程。 +- 当 `-t` 和 `-p` 同时出现时,所有 trace pattern(函数、函数名通配符、源码行、裸地址、模块限定地址)都在 `-t` 目标内解析。`-p` 只把运行时事件限制到该进程。 - `0xADDR` 的默认模块取决于启动模式:`-t ` 使用 ``;`-p ` 使用主可执行文件。如果 `-t` 和 `-p` 同时出现,目标解析以 `-t` 为准,`-p` 只把运行时事件限制到该 PID。 - `模块后缀:0xADDR` 可通过“全路径”或“唯一后缀”选中模块;若后缀不唯一,会提示候选项。在 `-t -p` 会话中,该模块必须匹配 `-t` 目标。 - 地址 trace 目标始终使用该模块的 DWARF/符号表虚拟地址。不要填写原始 ELF 文件偏移,也不要填写 `/proc//maps` 中 ASLR 后的运行时地址;GhostScope 会在内部把虚拟地址转换为 uprobe 需要的文件偏移。 diff --git a/e2e-tests/tests/script_execution.rs b/e2e-tests/tests/script_execution.rs index 8cc31d24..ee3792b0 100644 --- a/e2e-tests/tests/script_execution.rs +++ b/e2e-tests/tests/script_execution.rs @@ -517,6 +517,202 @@ async fn run_ghostscope_with_script( run_ghostscope_with_script_opt(script_content, timeout_secs, OptimizationLevel::Debug).await } +#[tokio::test] +async fn test_wildcard_function_tracing_compiles_prefix() -> anyhow::Result<()> { + init(); + + let binary_path = FIXTURES.get_test_binary("sample_program")?; + let analyzer = ghostscope_dwarf::DwarfAnalyzer::from_exec_path(&binary_path) + .await + .map_err(|e| anyhow::anyhow!("failed to load DWARF for sample_program: {e}"))?; + let script_content = r#" +trace get_* { + print "WILDCARD_COMPILE"; +} +"#; + let binary_path_string = binary_path.to_string_lossy().into_owned(); + let compile_options = ghostscope_compiler::CompileOptions { + binary_path_hint: Some(binary_path_string.clone()), + target_binary_path: Some(binary_path_string), + ..Default::default() + }; + let result = ghostscope_compiler::compile_script( + script_content, + &analyzer, + None, + Some(1), + &compile_options, + ) + .map_err(|e| anyhow::anyhow!("wildcard compile_script failed: {e}"))?; + + let function_names: std::collections::HashSet<_> = result + .uprobe_configs + .iter() + .filter_map(|config| config.function_name.as_deref()) + .collect(); + assert!(function_names.contains("get_random_value")); + assert!(function_names.contains("get_string_length")); + assert!(function_names.iter().all(|name| name.starts_with("get_"))); + assert!(result.uprobe_configs.iter().all(|config| matches!( + &config.trace_pattern, + ghostscope_compiler::script::TracePattern::Wildcard(pattern) if pattern == "get_*" + ))); + + let mut next_index_by_function = std::collections::HashMap::new(); + for config in &result.uprobe_configs { + let function_name = config + .function_name + .as_deref() + .expect("wildcard config should keep its concrete function name"); + let expected_index = next_index_by_function + .entry(function_name) + .or_insert(1usize); + assert_eq!(config.resolved_address_index, Some(*expected_index)); + *expected_index += 1; + + // TUI persistence rewrites an expanded wildcard probe to its concrete + // function and reloads it with this index. Verify that round trip picks + // the same module address instead of applying a wildcard-global index. + let replay_script = format!("trace {function_name} {{ print \"WILDCARD_REPLAY\"; }}"); + let replay_options = ghostscope_compiler::CompileOptions { + selected_index: config.resolved_address_index, + ..compile_options.clone() + }; + let replayed = ghostscope_compiler::compile_script( + &replay_script, + &analyzer, + None, + Some(1), + &replay_options, + ) + .map_err(|error| { + anyhow::anyhow!("failed to replay wildcard target {function_name}: {error}") + })?; + assert_eq!(replayed.uprobe_configs.len(), 1); + assert_eq!( + replayed.uprobe_configs[0].function_address, + config.function_address + ); + assert_eq!(replayed.uprobe_configs[0].binary_path, config.binary_path); + } + + let bounded = analyzer.lookup_function_addresses_by_prefix("get_", Some(&binary_path), 1); + assert_eq!(bounded.len(), 1, "prefix lookup must honor its bound"); + + let indexed_options = ghostscope_compiler::CompileOptions { + selected_index: Some(2), + ..compile_options.clone() + }; + let indexed_error = ghostscope_compiler::compile_script( + script_content, + &analyzer, + None, + Some(1), + &indexed_options, + ) + .expect_err("an indexed wildcard must be rejected"); + let indexed_message = indexed_error.user_message(); + assert!(indexed_message.contains("does not support an address index")); + assert!(indexed_message.contains("exact function name")); + + let error = ghostscope_compiler::compile_script( + r#"trace wildcard_target_that_does_not_exist_* { print "UNREACHABLE"; }"#, + &analyzer, + None, + Some(1), + &compile_options, + ) + .expect_err("a wildcard without matching functions must fail cleanly"); + let message = error.user_message(); + assert!(message.contains("wildcard_target_that_does_not_exist_*")); + assert!(message.contains("No addresses resolved for wildcard")); + Ok(()) +} + +#[tokio::test] +async fn test_wildcard_function_tracing_matches_elf_only_symbols() -> anyhow::Result<()> { + init(); + common::ensure_test_program_compiled_with_opt(OptimizationLevel::Stripped)?; + + let stripped = + FIXTURES.get_test_binary_with_opt("sample_program", OptimizationLevel::Stripped)?; + let tempdir = tempfile::tempdir()?; + let symbol_only_binary = tempdir.path().join("sample_program_symbol_only"); + std::fs::copy(&stripped, &symbol_only_binary)?; + + // The copied binary keeps its ELF symbols, but its .gnu_debuglink target is + // deliberately absent from the temporary directory. + let analyzer = ghostscope_dwarf::DwarfAnalyzer::from_exec_path(&symbol_only_binary) + .await + .map_err(|error| anyhow::anyhow!("failed to load symbol-only fixture: {error}"))?; + assert!( + !analyzer + .get_all_function_names() + .iter() + .any(|name| name.starts_with("get_")), + "fixture unexpectedly loaded DWARF function names" + ); + assert!( + !analyzer + .lookup_function_addresses("get_string_length") + .is_empty(), + "exact tracing should resolve the ELF text symbol" + ); + + let matches = + analyzer.lookup_function_addresses_by_prefix("get_", Some(&symbol_only_binary), 65); + let function_names: std::collections::HashSet<_> = matches + .iter() + .map(|matched| matched.function_name.as_str()) + .collect(); + assert!(function_names.contains("get_random_value")); + assert!(function_names.contains("get_string_length")); + Ok(()) +} + +#[tokio::test] +async fn test_wildcard_function_tracing_expands_prefix() -> anyhow::Result<()> { + init(); + ensure_global_cleanup_registered(); + + let script_content = r#" +trace get_* { + print "WILDCARD_PC={:p}", cast($pc, "unsigned char *"); +} +"#; + let target = get_global_test_target_with_opt(OptimizationLevel::Debug).await?; + let binary_path = FIXTURES.get_test_binary("sample_program")?; + let (exit_code, stdout, stderr) = common::runner::GhostscopeRunner::new() + .with_script(script_content) + .with_target(binary_path) + .attach_to(&target) + .timeout_secs(6) + .enable_sysmon_for_target(false) + .run() + .await?; + assert_eq!( + exit_code, 0, + "wildcard tracing failed: stderr={stderr}\nstdout={stdout}" + ); + + let pcs: std::collections::HashSet = stdout + .lines() + .filter_map(|line| line.split_once("WILDCARD_PC=").map(|(_, value)| value)) + .map(|value| { + value + .chars() + .take_while(|ch| ch.is_ascii_hexdigit() || *ch == 'x') + .collect::() + }) + .filter(|pc| !pc.is_empty()) + .collect(); + assert!( + pcs.len() >= 2, + "expected events from at least two get_* functions, got {pcs:?}; stdout={stdout}" + ); + Ok(()) +} + #[tokio::test] async fn test_capture_len_uses_scalar_script_var_from_dwarf_expr() -> anyhow::Result<()> { init(); diff --git a/ghostscope-compiler/src/lib.rs b/ghostscope-compiler/src/lib.rs index dfc39aff..5e475112 100644 --- a/ghostscope-compiler/src/lib.rs +++ b/ghostscope-compiler/src/lib.rs @@ -204,8 +204,10 @@ pub struct CompileOptions { pub max_trace_event_size: u32, /// Max DWARF-unwound frames captured by each `bt`/`backtrace` instruction. pub backtrace_depth: u8, - /// Optional single-address filter: if set, only the Nth (1-based) address - /// resolved for a target will be compiled. When None, compile all. + /// Optional single-address filter for non-wildcard targets: if set, only + /// the Nth (1-based) resolved address will be compiled. Wildcard targets + /// intentionally reject this option because their expansion has no + /// user-visible address index. When None, compile all. pub selected_index: Option, /// Optional PID filter strategy override. /// When None, compiler falls back to HostTgid using compile_script(pid). diff --git a/ghostscope-compiler/src/script/compiler.rs b/ghostscope-compiler/src/script/compiler.rs index fda0cb4d..d5922b20 100644 --- a/ghostscope-compiler/src/script/compiler.rs +++ b/ghostscope-compiler/src/script/compiler.rs @@ -1,7 +1,7 @@ use crate::script::ast::{Program, Statement, TracePattern}; use crate::CompileError; // BinaryAnalyzer is now internal to ghostscope-binary, use DwarfAnalyzer instead -use ghostscope_dwarf::ModuleDefaultPolicy; +use ghostscope_dwarf::{FunctionAddressMatch, ModuleDefaultPolicy}; use inkwell::context::Context; use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; @@ -9,6 +9,9 @@ use std::fmt::Write as _; use std::hash::{Hash, Hasher}; use tracing::{debug, error, info, warn}; +/// Maximum number of concrete uprobe addresses a wildcard may expand to. +const MAX_WILDCARD_RESOLVED_TARGETS: usize = 64; + /// Resolved target information from DWARF queries #[derive(Debug, Clone)] pub struct ResolvedTarget { @@ -28,7 +31,7 @@ pub struct UProbeConfig { /// Target binary path pub binary_path: String, - /// Function name (for FunctionName patterns) + /// Concrete function name (for exact and wildcard function patterns) pub function_name: Option, /// Resolved function address in the binary @@ -61,7 +64,7 @@ pub struct UProbeConfig { /// Optional eBPF tail-call step program used by the `bt` unwinder. pub backtrace_tail_call_program: Option, - /// Global 1-based index of this address within the resolved target list (if applicable) + /// 1-based index of this address within its persisted concrete target. pub resolved_address_index: Option, } @@ -139,6 +142,7 @@ impl<'a> AstCompiler<'a> { match stmt { Statement::TracePoint { pattern, body } => { debug!("Processing trace point {}: {:?}", index, pattern); + let failed_target_count = self.failed_targets.len(); match self.process_trace_point(pattern, body, pid, index) { Ok(_) => { successful_trace_points += 1; @@ -164,26 +168,12 @@ impl<'a> AstCompiler<'a> { // (e.g., when all addresses failed for a function) // If not, add a general failed target entry let has_failed_for_this_pattern = - self.failed_targets.iter().any(|ft| match pattern { - TracePattern::FunctionName(name) => ft.target_name == *name, - TracePattern::SourceLine { - file_path, - line_number, - } => ft.target_name == format!("{file_path}:{line_number}"), - TracePattern::Address(addr) => { - ft.target_name == format!("0x{addr:x}") - && ft.pc_address == *addr - } - TracePattern::AddressInModule { module, address } => { - ft.target_name == format!("{module}:0x{address:x}") - && ft.pc_address == *address - } - _ => false, - }); + self.failed_targets.len() > failed_target_count; if !has_failed_for_this_pattern { let target_name = match pattern { - TracePattern::FunctionName(name) => name.clone(), + TracePattern::FunctionName(name) + | TracePattern::Wildcard(name) => name.clone(), TracePattern::SourceLine { file_path, line_number, @@ -192,7 +182,6 @@ impl<'a> AstCompiler<'a> { TracePattern::AddressInModule { module, address } => { format!("{module}:0x{address:x}") } - _ => format!("trace_point_{index}"), }; let pc_address = match pattern { TracePattern::Address(addr) => *addr, @@ -543,11 +532,12 @@ impl<'a> AstCompiler<'a> { } TracePattern::FunctionName(func_name) => { // Resolve all addresses for the function name and generate per-PC programs - let module_addresses = if let Some(analyzer) = self.process_analyzer { - analyzer.lookup_function_addresses(func_name) - } else { - Vec::new() - }; + let analyzer = self.process_analyzer.ok_or_else(|| { + CompileError::Other( + "No process analyzer available to resolve function".to_string(), + ) + })?; + let module_addresses = analyzer.lookup_function_addresses(func_name); if module_addresses.is_empty() { // Strict behavior: fail this trace point immediately instead of skipping silently @@ -557,129 +547,214 @@ impl<'a> AstCompiler<'a> { } let original_address_count = module_addresses.len(); - let target_path = self.configured_target_path(); - let module_addresses = self - .process_analyzer - .ok_or_else(|| { - CompileError::Other( - "No process analyzer available to resolve -t target".to_string(), - ) - })? - .filter_module_addresses_to_target(module_addresses, target_path) + let target_path = self.configured_target_path().map(str::to_owned); + let module_addresses = analyzer + .filter_module_addresses_to_target(module_addresses, target_path.as_deref()) .map_err(|e| CompileError::Other(e.to_string()))?; if original_address_count > 0 && module_addresses.is_empty() { - let target = target_path.unwrap_or(""); + let target = target_path.as_deref().unwrap_or(""); return Err(CompileError::Other(format!( "No addresses resolved for function '{func_name}' in -t target '{target}'. When -t and -p are combined, -t takes precedence for trace target resolution." ))); } - let total_addresses: usize = module_addresses.len(); debug!( - "Resolved function '{}' to {} address(es) across {} modules", + "Resolved function '{}' to {} address(es)", func_name, - total_addresses, module_addresses.len() ); - // Validate optional single-index selection (1-based) - if let Some(idx) = self.compile_options.selected_index { - if idx == 0 || idx > module_addresses.len() { + let resolved_targets = module_addresses + .into_iter() + .enumerate() + .map(|(index, module_address)| FunctionAddressMatch { + function_name: func_name.clone(), + module_address, + function_address_index: index + 1, + }) + .collect(); + self.process_resolved_function_addresses( + pattern, + &format!("function '{func_name}'"), + resolved_targets, + statements, + pid, + &format!("Use 'info function {func_name}' to view indices."), + ) + } + TracePattern::Wildcard(wildcard_pattern) => { + Self::reject_wildcard_index(wildcard_pattern, self.compile_options.selected_index)?; + let prefix = wildcard_pattern + .strip_suffix('*') + .filter(|prefix| !prefix.is_empty()) + .ok_or_else(|| { + CompileError::Other(format!( + "Invalid wildcard '{wildcard_pattern}': use a non-empty function-name prefix followed by one trailing '*'" + )) + })?; + let analyzer = self.process_analyzer.ok_or_else(|| { + CompileError::Other( + "No process analyzer available to resolve wildcard".to_string(), + ) + })?; + let target_path = self.configured_target_path().map(str::to_owned); + let target_module = target_path + .as_deref() + .map(|target_path| analyzer.resolve_target_module_path(target_path)) + .transpose() + .map_err(|e| CompileError::Other(e.to_string()))?; + let resolved_targets = analyzer.lookup_function_addresses_by_prefix( + prefix, + target_module.as_deref(), + MAX_WILDCARD_RESOLVED_TARGETS + 1, + ); + + if resolved_targets.is_empty() { + if let Some(target) = target_path.as_deref() { return Err(CompileError::Other(format!( - "Selected index {idx} is out of range for function '{func_name}' (valid 1..={}). Use 'info function {func_name}' to view indices.", - module_addresses.len() + "No addresses resolved for wildcard '{wildcard_pattern}' in -t target '{target}'. When -t and -p are combined, -t takes precedence for trace target resolution." ))); } + return Err(CompileError::Other(format!( + "No addresses resolved for wildcard '{wildcard_pattern}' - no matching functions with attachable addresses were found" + ))); } - // We may need analyzer again to compute precise uprobe offsets - // Optional single-index filter (1-based); otherwise process all addresses - let mut successful_addresses = 0; - let mut failed_addresses = 0; - - // Iterate with indices (1-based) so we can propagate the global address index - let iterator: Box> = - if let Some(idx) = self.compile_options.selected_index { - let i = idx - 1; // safe due to validation above - Box::new(std::iter::once((idx, &module_addresses[i]))) - } else { - Box::new(module_addresses.iter().enumerate().map(|(i, m)| (i + 1, m))) - }; - - for (global_idx, module_address) in iterator { - // Convert DWARF function address (vaddr) to ELF file offset for uprobe attach - let file_off = self.process_analyzer.as_ref().and_then(|an| { - an.vaddr_to_file_offset(&module_address.module_path, module_address.address) - }); + Self::validate_wildcard_target_count(wildcard_pattern, resolved_targets.len())?; + debug!( + "Resolved wildcard '{}' to {} concrete address(es)", + wildcard_pattern, + resolved_targets.len() + ); - let target_info = ResolvedTarget { - function_name: Some(func_name.clone()), - function_address: Some(module_address.address), - binary_path: module_address.module_path.to_string_lossy().to_string(), - uprobe_offset: file_off, - pattern: pattern.clone(), - }; + self.process_resolved_function_addresses( + pattern, + &format!("wildcard '{wildcard_pattern}'"), + resolved_targets, + statements, + pid, + "Use a narrower prefix or an exact function name.", + ) + } + } + } - match self.generate_ebpf_for_target( - &target_info, - statements, - pid, - Some(global_idx), - ) { - Ok(uprobe_config) => { - self.uprobe_configs.push(uprobe_config); - successful_addresses += 1; - info!( - "✓ Successfully generated eBPF for function '{}' at 0x{:x}", - func_name, module_address.address - ); - } - Err(e) => { - failed_addresses += 1; - error!( - "❌ Failed to generate eBPF for function '{}' at 0x{:x}: {}", - func_name, module_address.address, e - ); + fn reject_wildcard_index( + wildcard_pattern: &str, + selected_index: Option, + ) -> Result<(), CompileError> { + if selected_index.is_some() { + return Err(CompileError::Other(format!( + "Wildcard target '{wildcard_pattern}' does not support an address index. Use a narrower prefix or an exact function name." + ))); + } + Ok(()) + } - // Record this failed target - self.failed_targets.push(FailedTarget { - target_name: func_name.clone(), - pc_address: module_address.address, - error_message: e.user_message().into_owned(), - }); + fn validate_wildcard_target_count( + wildcard_pattern: &str, + target_count: usize, + ) -> Result<(), CompileError> { + if target_count > MAX_WILDCARD_RESOLVED_TARGETS { + return Err(CompileError::Other(format!( + "Wildcard '{wildcard_pattern}' resolved to more than {MAX_WILDCARD_RESOLVED_TARGETS} concrete addresses. Use a narrower prefix or an exact function name." + ))); + } + Ok(()) + } - // Continue processing other addresses - } - } - } + fn process_resolved_function_addresses( + &mut self, + pattern: &TracePattern, + target_label: &str, + resolved_targets: Vec, + statements: &[Statement], + pid: Option, + selection_hint: &str, + ) -> Result<(), CompileError> { + if resolved_targets.is_empty() { + return Err(CompileError::Other(format!( + "No addresses resolved for {target_label}" + ))); + } + if let Some(idx) = self.compile_options.selected_index { + if idx == 0 || idx > resolved_targets.len() { + return Err(CompileError::Other(format!( + "Selected index {idx} is out of range for {target_label} (valid 1..={}). {selection_hint}", + resolved_targets.len() + ))); + } + } - // Log summary for this trace point - if successful_addresses > 0 && failed_addresses == 0 { + let mut successful_addresses = 0; + let mut failed_addresses = 0; + let targets: Box> = + if let Some(idx) = self.compile_options.selected_index { + Box::new(std::iter::once(&resolved_targets[idx - 1])) + } else { + Box::new(resolved_targets.iter()) + }; + + for resolved in targets { + let module_address = &resolved.module_address; + let file_off = self.process_analyzer.as_ref().and_then(|analyzer| { + analyzer.vaddr_to_file_offset(&module_address.module_path, module_address.address) + }); + let target_info = ResolvedTarget { + function_name: Some(resolved.function_name.clone()), + function_address: Some(module_address.address), + binary_path: module_address.module_path.to_string_lossy().to_string(), + uprobe_offset: file_off, + pattern: pattern.clone(), + }; + + match self.generate_ebpf_for_target( + &target_info, + statements, + pid, + Some(resolved.function_address_index), + ) { + Ok(uprobe_config) => { + self.uprobe_configs.push(uprobe_config); + successful_addresses += 1; info!( - "All {} addresses for function '{}' processed successfully", - successful_addresses, func_name + "✓ Successfully generated eBPF for function '{}' at 0x{:x}", + resolved.function_name, module_address.address ); - Ok(()) - } else if successful_addresses > 0 && failed_addresses > 0 { - warn!( - "Partial success for function '{}': {} successful, {} failed addresses", - func_name, successful_addresses, failed_addresses - ); - Ok(()) - } else { - // All addresses failed to process — record failures already captured above - // Defer final error shaping to the caller based on aggregated results + } + Err(e) => { + failed_addresses += 1; error!( - "All {} addresses for function '{}' failed to process", - failed_addresses, func_name + "❌ Failed to generate eBPF for function '{}' at 0x{:x}: {}", + resolved.function_name, module_address.address, e ); - Ok(()) + self.failed_targets.push(FailedTarget { + target_name: resolved.function_name.clone(), + pc_address: module_address.address, + error_message: e.user_message().into_owned(), + }); } } - _ => { - unimplemented!(); - } } + + if successful_addresses > 0 && failed_addresses == 0 { + info!( + "All {} addresses for {} processed successfully", + successful_addresses, target_label + ); + } else if successful_addresses > 0 && failed_addresses > 0 { + warn!( + "Partial success for {}: {} successful, {} failed addresses", + target_label, successful_addresses, failed_addresses + ); + } else { + // Failures were recorded above; preserve partial-compilation behavior. + error!( + "All {} addresses for {} failed to process", + failed_addresses, target_label + ); + } + Ok(()) } /// Generate eBPF bytecode for resolved target @@ -1041,3 +1116,35 @@ impl<'a> AstCompiler<'a> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::{AstCompiler, MAX_WILDCARD_RESOLVED_TARGETS}; + + #[test] + fn wildcard_expansion_limit_allows_only_bounded_targets() { + assert!(AstCompiler::validate_wildcard_target_count( + "get_*", + MAX_WILDCARD_RESOLVED_TARGETS, + ) + .is_ok()); + + let error = + AstCompiler::validate_wildcard_target_count("get_*", MAX_WILDCARD_RESOLVED_TARGETS + 1) + .expect_err("a wildcard above the limit must fail"); + let message = error.user_message(); + assert!(message.contains("get_*")); + assert!(message.contains(&MAX_WILDCARD_RESOLVED_TARGETS.to_string())); + assert!(message.contains("narrower prefix")); + } + + #[test] + fn wildcard_rejects_address_index() { + let error = AstCompiler::reject_wildcard_index("get_*", Some(2)) + .expect_err("a wildcard address index must fail"); + let message = error.user_message(); + assert!(message.contains("get_*")); + assert!(message.contains("does not support an address index")); + assert!(message.contains("exact function name")); + } +} diff --git a/ghostscope-compiler/src/script/parser/tests.rs b/ghostscope-compiler/src/script/parser/tests.rs index 13f8320e..77513b7e 100644 --- a/ghostscope-compiler/src/script/parser/tests.rs +++ b/ghostscope-compiler/src/script/parser/tests.rs @@ -1,5 +1,5 @@ use super::{parse, ParseError}; -use crate::script::ast::{BinaryOp, Expr, PrintStatement, Statement}; +use crate::script::ast::{BinaryOp, Expr, PrintStatement, Statement, TracePattern}; #[test] fn parse_memcmp_builtin_in_if_should_succeed() { @@ -639,7 +639,14 @@ fn parse_trace_patterns_function_line_address_wildcard() { // Wildcard let s4 = r#"trace printf* { print "W"; }"#; - assert!(parse(s4).is_ok()); + let wildcard_program = parse(s4).expect("wildcard trace should parse"); + assert!(matches!( + &wildcard_program.statements[0], + Statement::TracePoint { + pattern: TracePattern::Wildcard(pattern), + .. + } if pattern == "printf*" + )); // Module-qualified address let s5 = r#"trace /lib/x86_64-linux-gnu/libc.so.6:0x1234 { print "M"; }"#; diff --git a/ghostscope-dwarf/src/analyzer/mod.rs b/ghostscope-dwarf/src/analyzer/mod.rs index bd3996b9..dd95130d 100644 --- a/ghostscope-dwarf/src/analyzer/mod.rs +++ b/ghostscope-dwarf/src/analyzer/mod.rs @@ -11,7 +11,7 @@ use crate::{ }; use ghostscope_debuginfod::DebuginfodClient; use object::{Object, ObjectSection}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; @@ -818,6 +818,67 @@ impl DwarfAnalyzer { results } + /// Lookup a bounded number of concrete function addresses whose names start + /// with `prefix`. + /// + /// The optional module scope is applied before name enumeration or DWARF + /// materialization. Results use a stable function-name/module/address order. + /// Function aliases that resolve to the same module address are collapsed, + /// and resolution stops once `max_results` unique addresses are found. + pub fn lookup_function_addresses_by_prefix( + &self, + prefix: &str, + module_scope: Option<&Path>, + max_results: usize, + ) -> Vec { + if prefix.is_empty() || max_results == 0 { + return Vec::new(); + } + + let mut modules: Vec<_> = self + .modules + .iter() + .filter(|(module_path, _)| { + module_scope + .is_none_or(|scope| Self::module_paths_equivalent(module_path.as_path(), scope)) + }) + .collect(); + modules.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut function_names = BTreeSet::new(); + for (_, module_data) in &modules { + function_names.extend( + module_data + .get_attachable_function_names() + .into_iter() + .filter(|name| name.starts_with(prefix)), + ); + } + + let mut results = Vec::new(); + let mut seen_module_addresses = HashSet::new(); + for function_name in function_names { + let mut function_address_index = 0; + for (module_path, module_data) in &modules { + for address in module_data.lookup_function_addresses_any(&function_name) { + function_address_index += 1; + let module_address = ModuleAddress::new((*module_path).clone(), address); + if seen_module_addresses.insert(module_address.clone()) { + results.push(FunctionAddressMatch { + function_name: function_name.clone(), + module_address, + function_address_index, + }); + if results.len() == max_results { + return results; + } + } + } + } + } + results + } + /// Query function debug information across all modules. pub fn query_function(&self, name: &str) -> Result { let module_addresses = self.lookup_function_addresses(name); @@ -1196,7 +1257,7 @@ impl DwarfAnalyzer { all_functions } - /// Lookup functions by pattern (simplified - exact match only for now) + /// Lookup functions whose names contain a literal substring. pub fn lookup_functions_by_pattern(&self, pattern: &str) -> Vec { let all_functions = self.list_functions(); all_functions diff --git a/ghostscope-dwarf/src/analyzer/types.rs b/ghostscope-dwarf/src/analyzer/types.rs index 683ac492..6efb6695 100644 --- a/ghostscope-dwarf/src/analyzer/types.rs +++ b/ghostscope-dwarf/src/analyzer/types.rs @@ -1,4 +1,7 @@ -use crate::{core::DebugInfoSource, semantics::VisibleVariable}; +use crate::{ + core::{DebugInfoSource, ModuleAddress}, + semantics::VisibleVariable, +}; use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,6 +105,15 @@ pub struct FunctionQueryResult { pub addresses: Vec, } +/// A concrete function entry resolved for tracing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FunctionAddressMatch { + pub function_name: String, + pub module_address: ModuleAddress, + /// 1-based address index within this concrete function after module scoping. + pub function_address_index: usize, +} + /// Module statistics compatible with ghostscope-binary #[derive(Debug, Clone)] pub struct ModuleStats { diff --git a/ghostscope-dwarf/src/lib.rs b/ghostscope-dwarf/src/lib.rs index b5eff982..1adfcf8b 100644 --- a/ghostscope-dwarf/src/lib.rs +++ b/ghostscope-dwarf/src/lib.rs @@ -24,9 +24,10 @@ pub(crate) mod analyzer; // Re-export main public API only pub use analyzer::{ AddressQueryResult, AnalyzerStats, DwarfAnalyzer, DwarfIndexStatus, ExecutableFileInfo, - FunctionQueryResult, LoadedModuleRuntimeInfo, MainExecutableInfo, ModuleDefaultPolicy, - ModuleLoadingEvent, ModuleLoadingStats, ModuleStats, SectionInfo, SharedLibraryInfo, - SimpleFileInfo, SourceLineAddressSearch, SourceLineQuerySearch, TypeLookupAmbiguity, + FunctionAddressMatch, FunctionQueryResult, LoadedModuleRuntimeInfo, MainExecutableInfo, + ModuleDefaultPolicy, ModuleLoadingEvent, ModuleLoadingStats, ModuleStats, SectionInfo, + SharedLibraryInfo, SimpleFileInfo, SourceLineAddressSearch, SourceLineQuerySearch, + TypeLookupAmbiguity, }; pub use loader::ExplicitDebugFile; diff --git a/ghostscope-dwarf/src/objfile/loaded.rs b/ghostscope-dwarf/src/objfile/loaded.rs index 50ff92ae..c80ab9ad 100644 --- a/ghostscope-dwarf/src/objfile/loaded.rs +++ b/ghostscope-dwarf/src/objfile/loaded.rs @@ -172,6 +172,20 @@ impl LoadedObjfile { .collect() } + /// Return every function name that can seed an address lookup. + /// + /// DWARF/GDB indexes provide source-aware names, while the ELF symbol cache + /// keeps exact tracing available for stripped modules. Prefix lookup must + /// consider both sources so it has the same attachability semantics as an + /// exact function lookup. + pub(crate) fn get_attachable_function_names(&self) -> Vec { + let mut names = self.get_function_names(); + names.extend(self.text_symbol_starts_by_name.keys().cloned()); + names.sort(); + names.dedup(); + names + } + pub(crate) fn get_variable_names(&self) -> Vec { if let Some(index) = &self.gdb_index { match index.symbol_names(GdbSymbolKind::Variable) { diff --git a/ghostscope-ui/src/components/command_panel/command_parser.rs b/ghostscope-ui/src/components/command_panel/command_parser.rs index e6693407..4f49b136 100644 --- a/ghostscope-ui/src/components/command_panel/command_parser.rs +++ b/ghostscope-ui/src/components/command_panel/command_parser.rs @@ -480,8 +480,8 @@ impl CommandParser { fn format_tracing_commands() -> String { [ "📊 Tracing Commands:", - " trace - Start tracing a function/line/address (t)", - " - target: function_name | file:line | 0xADDR | module_suffix:0xADDR", + " trace - Start tracing a function/prefix/line/address (t)", + " - target: function_name | function_prefix* | file:line | 0xADDR | module_suffix:0xADDR", " enable - Enable specific trace or all traces (en)", " disable - Disable specific trace or all traces (dis)", " delete - Delete specific trace or all traces (del)", diff --git a/ghostscope-ui/src/components/command_panel/script_editor.rs b/ghostscope-ui/src/components/command_panel/script_editor.rs index 61c1698e..c016afea 100644 --- a/ghostscope-ui/src/components/command_panel/script_editor.rs +++ b/ghostscope-ui/src/components/command_panel/script_editor.rs @@ -29,14 +29,33 @@ impl ScriptEditor { /// Enter script editing mode for a trace command pub fn enter_script_mode(state: &mut CommandPanelState, command: &str) -> Vec { let rest = command.trim_start_matches("trace").trim(); - // Support optional index: trace [index] + // Support an optional index for non-wildcard targets: trace [index] let mut parts = rest.split_whitespace(); let base_target = parts.next().unwrap_or(""); let index_opt = parts.next().and_then(|s| s.parse::().ok()); if base_target.is_empty() { - let plain = - "Usage: trace ".to_string(); + let plain = "Usage: trace " + .to_string(); + let styled = vec![ + crate::components::command_panel::style_builder::StyledLineBuilder::new() + .styled( + plain.clone(), + crate::components::command_panel::style_builder::StylePresets::ERROR, + ) + .build(), + ]; + return vec![Action::AddResponseWithStyle { + content: plain, + styled_lines: Some(styled), + response_type: ResponseType::Error, + }]; + } + + if base_target.ends_with('*') && index_opt.is_some() { + let plain = format!( + "Wildcard target '{base_target}' does not support an address index. Use a narrower prefix or an exact function name." + ); let styled = vec![ crate::components::command_panel::style_builder::StyledLineBuilder::new() .styled( @@ -919,3 +938,39 @@ impl ScriptEditor { .map_or(text.len(), |(pos, _)| pos) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wildcard_trace_rejects_address_index_before_opening_editor() { + let mut state = CommandPanelState::new(); + + let actions = ScriptEditor::enter_script_mode(&mut state, "trace get_* 2"); + + assert_eq!(state.mode, InteractionMode::Input); + assert!(state.script_cache.is_none()); + assert!(matches!( + actions.as_slice(), + [Action::AddResponseWithStyle { + content, + response_type: ResponseType::Error, + .. + }] if content.contains("does not support an address index") + && content.contains("exact function name") + )); + } + + #[test] + fn wildcard_trace_without_index_opens_editor() { + let mut state = CommandPanelState::new(); + + ScriptEditor::enter_script_mode(&mut state, "trace get_*"); + + assert_eq!(state.mode, InteractionMode::ScriptEditor); + let cache = state.script_cache.expect("wildcard script cache"); + assert_eq!(cache.target, "get_*"); + assert_eq!(cache.selected_index, None); + } +} diff --git a/ghostscope-ui/src/ui/strings.rs b/ghostscope-ui/src/ui/strings.rs index 353e917d..d5dd1d1d 100644 --- a/ghostscope-ui/src/ui/strings.rs +++ b/ghostscope-ui/src/ui/strings.rs @@ -26,7 +26,7 @@ impl UIStrings { // Help text pub const HELP_TEXT: &'static str = r#"Available commands: help - Show this help message - trace - Start tracing a function/line/address (enters script mode) + trace - Start tracing a function/prefix/line/address (enters script mode) attach - Attach to a process by PID detach - Detach from current process quit - Exit ghostscope