diff --git a/docs/input-commands.md b/docs/input-commands.md index 49aaea17..1499c375 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,12 @@ 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. + - 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 +190,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 +288,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..282fc187 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,28 @@ 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. + +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 +116,7 @@ trace /home/user/project/src/utils.c:100 { ``` #### Address + ```ghostscope // Module-relative virtual address (DWARF/symbol PC) trace 0x401234 { @@ -107,7 +130,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..239cc76d 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,12 @@ trace main # 你之前的脚本会被恢复! - 内联:选择“内联实例的起始位置”(inline DIE 的 low_pc 语义),这是“入口类”的落点,未必对齐语句边界;若需要精确落在语句起始,建议使用行号目标。 - 多个内联实例:若函数在多个调用点被内联,会为每个内联实例各返回一个地址(若另有非内联定义,另加 1 个)。 +- 函数名通配符目标(`trace { ... }`): + - 按字面前缀匹配;只支持一个末尾 `*`,不是通用 glob 或正则表达式。 + - 按函数名、模块路径、地址的稳定顺序展开为具体函数地址,并对同一模块地址的别名去重。 + - 一个通配符最多展开 64 个具体地址。超过上限时请缩小前缀或使用完整函数名。 + - 仅使用 `-p` 时会搜索 setup 阶段已知的所有模块;使用 `-t` 时,匹配范围仅限该目标模块。 + - 源码行号目标(`trace { ... }`): - 对应“语句级”语义:解析到该行的语句边界(is_stmt)并在每个出现处各给一个地址(每个内联实例各一处)。 - 通常会在所有出现该行的实例处各有 1 个地址。 @@ -179,7 +188,7 @@ s t [file] # 缩写形式 **行为:** - 以 `trace { ... }` 区块形式保存每条追踪及元信息。 -- 若追踪是通过 `trace [index]` 创建的,会将所选“地址序号”一并保存,并在加载时恢复该序号。 +- 若非通配符追踪是通过 `trace [index]` 创建的,会将所选“地址序号”一并保存,并在加载时恢复该序号。 **示例:** ``` @@ -277,7 +286,7 @@ s # 缩写形式(但不包括 "s t") **行为:** - 加载文件中的所有 `trace { ... }` 区块。 -- 若区块内包含保存的“地址序号”,则仅为该目标重新挂载对应序号的地址。 +- 若非通配符区块内包含保存的“地址序号”,则仅为该目标重新挂载对应序号的地址。 **示例:** ``` diff --git a/docs/zh/scripting.md b/docs/zh/scripting.md index 8092421a..224f0a7c 100644 --- a/docs/zh/scripting.md +++ b/docs/zh/scripting.md @@ -64,6 +64,7 @@ trace <模式> { ### 追踪模式 #### 函数名 + ```ghostscope trace main { print "Main 函数被调用"; @@ -74,7 +75,25 @@ trace calculate_something { } ``` +#### 函数名前缀通配符 + +```ghostscope +trace get_* { + print "命中了 get_ 前缀函数"; +} +``` + +通配符语法是字面函数名前缀加一个末尾 `*`。例如,`get_*` 会匹配 +`get_random_value` 和 `get_string_length`;它不是通用 glob 或正则表达式。 +GhostScope 会为每个解析出的具体地址创建一个探针,顺序固定为函数名、模块路径、 +地址;同一模块地址的别名只会 attach 一次。 + +一个通配符最多展开为 64 个具体地址;超过安全上限时请缩小前缀或使用完整函数名。 +仅使用 `-p` 时会匹配开始追踪时已知的所有模块;可添加 `-t `,把较宽的 +前缀限定到一个模块。 + #### 源代码行 + ```ghostscope // 追踪特定文件和行号 trace sample.c:42 { @@ -88,6 +107,7 @@ trace /home/user/project/src/utils.c:100 { ``` #### 地址 + ```ghostscope // 按模块相对虚拟地址(DWARF/符号表 PC)追踪 trace 0x401234 { @@ -101,7 +121,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..f463a8cf 100644 --- a/e2e-tests/tests/script_execution.rs +++ b/e2e-tests/tests/script_execution.rs @@ -517,6 +517,128 @@ 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_*" + ))); + assert_eq!( + result + .uprobe_configs + .iter() + .filter_map(|config| config.resolved_address_index) + .collect::>(), + (1..=result.uprobe_configs.len()).collect::>() + ); + + 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_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..d4e3b970 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,10 @@ 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 unless +/// Input Mode selects one address explicitly. +const MAX_WILDCARD_RESOLVED_TARGETS: usize = 64; + /// Resolved target information from DWARF queries #[derive(Debug, Clone)] pub struct ResolvedTarget { @@ -28,7 +32,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 @@ -139,6 +143,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 +169,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 +183,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 +533,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 +548,217 @@ 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() { - 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() - ))); - } - } - - // 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; + let resolved_targets = module_addresses + .into_iter() + .map(|module_address| FunctionAddressMatch { + function_name: func_name.clone(), + module_address, + }) + .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 mut resolved_targets = analyzer.lookup_function_addresses_by_prefix(prefix); - // 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))) - }; + if resolved_targets.is_empty() { + return Err(CompileError::Other(format!( + "No addresses resolved for wildcard '{wildcard_pattern}' - no matching functions with attachable addresses were found" + ))); + } - 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) + let original_address_count = resolved_targets.len(); + let target_path = self.configured_target_path().map(str::to_owned); + if let Some(target_path) = target_path.as_deref() { + let target_module = analyzer + .resolve_target_module_path(target_path) + .map_err(|e| CompileError::Other(e.to_string()))?; + resolved_targets.retain(|matched| { + ghostscope_dwarf::DwarfAnalyzer::module_paths_equivalent( + &matched.module_address.module_path, + &target_module, + ) }); + } + if original_address_count > 0 && resolved_targets.is_empty() { + let target = target_path.as_deref().unwrap_or(""); + return Err(CompileError::Other(format!( + "No addresses resolved for wildcard '{wildcard_pattern}' in -t target '{target}'. When -t and -p are combined, -t takes precedence for trace target resolution." + ))); + } - 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::validate_wildcard_target_count(wildcard_pattern, resolved_targets.len())?; + debug!( + "Resolved wildcard '{}' to {} concrete address(es)", + wildcard_pattern, + resolved_targets.len() + ); - 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 - ); + self.process_resolved_function_addresses( + pattern, + &format!("wildcard '{wildcard_pattern}'"), + resolved_targets, + statements, + pid, + "Use a narrower prefix or an exact function name.", + ) + } + } + } - // 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 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(()) + } - // Continue processing other addresses - } - } - } + 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 {target_count} concrete addresses, exceeding the safety limit of {MAX_WILDCARD_RESOLVED_TARGETS}. Use a narrower prefix or an exact function name." + ))); + } + Ok(()) + } - // Log summary for this trace point - if successful_addresses > 0 && failed_addresses == 0 { + 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() + ))); + } + } + + 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((idx, &resolved_targets[idx - 1]))) + } else { + Box::new( + resolved_targets + .iter() + .enumerate() + .map(|(index, resolved)| (index + 1, resolved)), + ) + }; + + for (global_index, 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(global_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 +1120,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..f6011850 100644 --- a/ghostscope-dwarf/src/analyzer/mod.rs +++ b/ghostscope-dwarf/src/analyzer/mod.rs @@ -818,6 +818,66 @@ impl DwarfAnalyzer { results } + /// Lookup concrete function addresses whose names start with `prefix`. + /// + /// Results use a stable function-name/module/address order. Function aliases + /// that resolve to the same module address are collapsed so callers do not + /// attach duplicate probes at one instruction. + pub fn lookup_function_addresses_by_prefix(&self, prefix: &str) -> Vec { + if prefix.is_empty() { + return Vec::new(); + } + + let mut results = Vec::new(); + for (module_path, module_data) in &self.modules { + let mut function_names = module_data.get_function_names(); + function_names.sort(); + function_names.dedup(); + + for function_name in function_names + .into_iter() + .filter(|name| name.starts_with(prefix)) + { + for address in module_data.lookup_function_addresses_any(&function_name) { + results.push(FunctionAddressMatch { + function_name: function_name.clone(), + module_address: ModuleAddress::new(module_path.clone(), address), + }); + } + } + } + + // Resolve aliases deterministically before collapsing identical module + // addresses, then restore the user-facing function/module/address order. + results.sort_by(|left, right| { + left.module_address + .module_path + .cmp(&right.module_address.module_path) + .then_with(|| { + left.module_address + .address + .cmp(&right.module_address.address) + }) + .then_with(|| left.function_name.cmp(&right.function_name)) + }); + results.dedup_by(|left, right| left.module_address == right.module_address); + results.sort_by(|left, right| { + left.function_name + .cmp(&right.function_name) + .then_with(|| { + left.module_address + .module_path + .cmp(&right.module_address.module_path) + }) + .then_with(|| { + left.module_address + .address + .cmp(&right.module_address.address) + }) + }); + 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 +1256,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..c2c5131c 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,13 @@ pub struct FunctionQueryResult { pub addresses: Vec, } +/// A concrete function entry resolved from a function-name prefix. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FunctionAddressMatch { + pub function_name: String, + pub module_address: ModuleAddress, +} + /// 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-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