diff --git a/docs/configuration.md b/docs/configuration.md index e7935f5f..b76b18c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -226,6 +226,8 @@ Behavior: | `--script-file ` | | Script file to execute | None | | `--script-help` | | Print the embedded script language reference and exit | Off | | `--script-output ` | | Script event stdout mode: pretty, plain | pretty | +| `--dry-run` | | Compile the script, resolve trace targets, and exit without attaching uprobes. Requires the same eBPF privileges and kernel capabilities as a real run. | Off | +| `--dry-run-details` | | Include source, inline, and variable diagnostics in dry-run output; requires `--dry-run` | Off | | `--status` | | Enable interactive DWARF/script/attach stderr status prompts | On | | `--no-status` | | Disable interactive DWARF/script/attach stderr status prompts | Off override | | `--script-timestamp ` | | Pretty output timestamp: local, boot, none | local | diff --git a/docs/scripting.md b/docs/scripting.md index 19c542bb..6ffa36aa 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -43,6 +43,21 @@ GhostScope supports the following statements: The `trace` statement is the top‑level construct used only at the script file level (not nested inside other trace blocks). +Before attaching uprobes, you can validate a script and inspect the resolved +targets: + +```bash +ghostscope -p 1234 --script-file trace.gs --dry-run +ghostscope -p 1234 --script-file trace.gs --dry-run --dry-run-details +``` + +`--dry-run` parses DWARF, compiles the script, resolves PCs and uprobe file +offsets, then exits without attaching. It still performs the same startup +privilege and kernel capability checks as a real run, so use `sudo` or +equivalent eBPF privileges when your system requires them. `--dry-run-details` +adds source locations, inline classification, script-used variables, visible +variables, and optimized-out/unavailable variable diagnostics. + ### Syntax ```ghostscope diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 35f87400..ce56cef9 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -227,6 +227,8 @@ ghostscope bpffs prune --dry-run --json | `--script-file ` | | 要执行的脚本文件 | 无 | | `--script-help` | | 输出内嵌的脚本语言参考并退出 | 关 | | `--script-output ` | | 脚本事件 stdout 模式:pretty, plain | pretty | +| `--dry-run` | | 编译脚本、解析 trace 目标,然后退出,不 attach uprobe。需要与真实运行相同的 eBPF 权限和内核能力。 | 关 | +| `--dry-run-details` | | 在 dry-run 输出中包含源码、inline 和变量诊断;需要同时使用 `--dry-run` | 关 | | `--status` | | 启用交互式 DWARF/脚本/attach stderr 状态提示 | 开 | | `--no-status` | | 禁用交互式 DWARF/脚本/attach stderr 状态提示 | 关闭覆盖 | | `--script-timestamp ` | | pretty 输出时间戳:local, boot, none | local | diff --git a/docs/zh/scripting.md b/docs/zh/scripting.md index 610fafce..5407113d 100644 --- a/docs/zh/scripting.md +++ b/docs/zh/scripting.md @@ -43,6 +43,15 @@ GhostScope 支持以下语句类型: `trace` 语句是定义追踪点的顶层结构。它只在脚本文件中使用,不在追踪块内部使用。 +在真正 attach uprobe 之前,可以先验证脚本并查看解析结果: + +```bash +ghostscope -p 1234 --script-file trace.gs --dry-run +ghostscope -p 1234 --script-file trace.gs --dry-run --dry-run-details +``` + +`--dry-run` 会解析 DWARF、编译脚本、解析 PC 和 uprobe 文件偏移,然后直接退出,不 attach。它仍会执行与真实运行相同的启动权限和内核能力检查;如果当前系统要求,请使用 `sudo` 或等价 eBPF 权限。`--dry-run-details` 会追加源码位置、inline 分类、脚本用到的变量、当前 PC 可见变量,以及 optimized-out/不可用变量诊断。 + ### 语法 ```ghostscope diff --git a/ghostscope/src/cli/dry_run.rs b/ghostscope/src/cli/dry_run.rs new file mode 100644 index 00000000..f1a8369a --- /dev/null +++ b/ghostscope/src/cli/dry_run.rs @@ -0,0 +1,316 @@ +use ghostscope_compiler::{CompilationResult, UProbeConfig}; +use ghostscope_dwarf::{ + Availability, DwarfAnalyzer, ModuleAddress, Provenance, RuntimeCapabilities, + VariableLoweringKind, VariableQueryDiagnostic, VariableReadPlan, VisibleVariable, +}; + +const VARIABLE_DISPLAY_LIMIT: usize = 40; + +#[derive(Debug, Clone, Copy)] +pub struct DryRunReportOptions { + pub details: bool, +} + +pub fn print_dry_run_report( + result: &CompilationResult, + analyzer: Option<&DwarfAnalyzer>, + runtime_capabilities: &RuntimeCapabilities, + options: DryRunReportOptions, +) { + let attachable_configs: Vec<_> = result + .uprobe_configs + .iter() + .filter(|config| is_attachable_config(config)) + .collect(); + let unattachable_configs: Vec<_> = result + .uprobe_configs + .iter() + .filter(|config| !is_attachable_config(config)) + .collect(); + + println!("GhostScope dry run: no uprobes were attached."); + println!( + "Summary: {} attachable target(s), {} unattachable resolved target(s), {} failed target(s), next trace id {}", + attachable_configs.len(), + unattachable_configs.len(), + result.failed_targets.len(), + result.next_available_trace_id + ); + if !result.target_info.is_empty() { + println!("Primary target: {}", result.target_info); + } + + if attachable_configs.is_empty() { + println!("\nAttachable targets: none"); + } else { + println!("\nAttachable targets:"); + for (index, config) in attachable_configs.iter().enumerate() { + print_target( + index + 1, + config, + analyzer, + runtime_capabilities, + options.details, + ); + } + } + + if !unattachable_configs.is_empty() { + println!("\nResolved but not attachable targets:"); + for (index, config) in unattachable_configs.iter().enumerate() { + print_target( + index + 1, + config, + analyzer, + runtime_capabilities, + options.details, + ); + } + } + + if !result.failed_targets.is_empty() { + println!("\nFailed targets:"); + for failed in &result.failed_targets { + println!( + " - {} at 0x{:x}: {}", + failed.target_name, failed.pc_address, failed.error_message + ); + } + } +} + +fn is_attachable_config(config: &UProbeConfig) -> bool { + config.uprobe_offset.is_some() +} + +fn print_target( + index: usize, + config: &UProbeConfig, + analyzer: Option<&DwarfAnalyzer>, + runtime_capabilities: &RuntimeCapabilities, + details: bool, +) { + let pc = config.function_address.unwrap_or(0); + let target = target_label(config); + let offset = config + .uprobe_offset + .map(|offset| format!("0x{offset:x}")) + .unwrap_or_else(|| "".to_string()); + + println!( + " [{index}] {} -> pc=0x{pc:x}, uprobe_offset={}, module={}", + target, offset, config.binary_path + ); + + if !details { + return; + } + + println!(" trace_id: {}", config.assigned_trace_id); + println!(" eBPF program: {}", config.ebpf_function_name); + println!(" pattern: {}", trace_pattern_label(config)); + println!(" attachable: {}", attachable_label(config)); + if let Some(address_index) = config.resolved_address_index { + println!(" resolved address index: {address_index}"); + } + if let Some(source) = source_label(config, analyzer) { + println!(" source: {source}"); + } + if let Some(inline) = inline_label(config, analyzer) { + println!(" inline context: {inline}"); + } + print_used_variables(config); + print_visible_variables(config, analyzer, runtime_capabilities); +} + +fn target_label(config: &UProbeConfig) -> String { + config + .function_name + .clone() + .unwrap_or_else(|| format!("0x{:x}", config.function_address.unwrap_or(0))) +} + +fn trace_pattern_label(config: &UProbeConfig) -> String { + use ghostscope_compiler::script::TracePattern; + + match &config.trace_pattern { + TracePattern::FunctionName(name) => format!("function {name}"), + TracePattern::Wildcard(pattern) => format!("wildcard {pattern}"), + TracePattern::Address(address) => format!("address 0x{address:x}"), + TracePattern::AddressInModule { module, address } => { + format!("address {module}:0x{address:x}") + } + TracePattern::SourceLine { + file_path, + line_number, + } => format!("source {file_path}:{line_number}"), + } +} + +fn attachable_label(config: &UProbeConfig) -> &'static str { + if config.uprobe_offset.is_some() && !config.ebpf_bytecode.is_empty() { + "yes (file offset resolved; eBPF bytecode generated)" + } else if config.uprobe_offset.is_some() { + "partial (file offset resolved; empty eBPF bytecode)" + } else { + "no (file offset unresolved)" + } +} + +fn module_address(config: &UProbeConfig) -> Option { + config.function_address.map(|address| { + ModuleAddress::new( + std::path::PathBuf::from(config.binary_path.clone()), + address, + ) + }) +} + +fn source_label(config: &UProbeConfig, analyzer: Option<&DwarfAnalyzer>) -> Option { + let analyzer = analyzer?; + let module_address = module_address(config)?; + let source = analyzer.lookup_source_location(&module_address)?; + let column = source + .column + .map(|column| format!(":{column}")) + .unwrap_or_default(); + Some(format!( + "{}:{}{}", + source.file_path, source.line_number, column + )) +} + +fn inline_label(config: &UProbeConfig, analyzer: Option<&DwarfAnalyzer>) -> Option<&'static str> { + let analyzer = analyzer?; + let module_address = module_address(config)?; + analyzer + .is_inline_at(&module_address) + .map(|is_inline| if is_inline { "yes" } else { "no" }) +} + +fn print_used_variables(config: &UProbeConfig) { + if config.trace_context.variable_names.is_empty() { + println!(" variables used by script: none"); + return; + } + + println!( + " variables used by script: {}", + config.trace_context.variable_names.join(", ") + ); +} + +fn print_visible_variables( + config: &UProbeConfig, + analyzer: Option<&DwarfAnalyzer>, + runtime_capabilities: &RuntimeCapabilities, +) { + let Some(analyzer) = analyzer else { + println!(" visible variables: unavailable (no DWARF analyzer)"); + return; + }; + let Some(module_address) = module_address(config) else { + println!(" visible variables: unavailable (no PC)"); + return; + }; + + let visible = analyzer + .resolve_pc(&module_address) + .and_then(|ctx| analyzer.visible_variables_with_diagnostics(&ctx)); + + match visible { + Ok(result) => { + print_visible_variable_list(&result.variables, runtime_capabilities); + print_variable_diagnostics(&result.diagnostics); + } + Err(error) => { + println!(" visible variables: unavailable ({error})"); + } + } +} + +fn print_visible_variable_list( + variables: &[VisibleVariable], + runtime_capabilities: &RuntimeCapabilities, +) { + if variables.is_empty() { + println!(" visible variables: none"); + return; + } + + println!( + " visible variables: {} shown{}", + variables.len().min(VARIABLE_DISPLAY_LIMIT), + if variables.len() > VARIABLE_DISPLAY_LIMIT { + format!(" of {}", variables.len()) + } else { + String::new() + } + ); + + for variable in variables.iter().take(VARIABLE_DISPLAY_LIMIT) { + let plan = VariableReadPlan::from_visible_variable(variable.clone(), Provenance::DirectDie); + let materialization = plan.materialization_plan(runtime_capabilities); + println!( + " - {}{}: {} [{}; {}; {}]", + if variable.is_parameter { "param " } else { "" }, + variable.name, + variable.type_name, + availability_label(&materialization.availability), + lowering_label(&materialization.lowering.kind), + variable.location + ); + } + + if variables.len() > VARIABLE_DISPLAY_LIMIT { + println!( + " ... {} more variable(s) omitted", + variables.len() - VARIABLE_DISPLAY_LIMIT + ); + } +} + +fn print_variable_diagnostics(diagnostics: &[VariableQueryDiagnostic]) { + if diagnostics.is_empty() { + return; + } + + println!(" variable diagnostics:"); + for diagnostic in diagnostics.iter().take(VARIABLE_DISPLAY_LIMIT) { + let name = diagnostic.name.as_deref().unwrap_or(""); + println!( + " - {}: {} [{}]", + name, + diagnostic.detail, + availability_label(&diagnostic.availability) + ); + } + + if diagnostics.len() > VARIABLE_DISPLAY_LIMIT { + println!( + " ... {} more diagnostic(s) omitted", + diagnostics.len() - VARIABLE_DISPLAY_LIMIT + ); + } +} + +fn availability_label(availability: &Availability) -> String { + match availability { + Availability::Available => "available".to_string(), + Availability::PartiallyAvailable => "partially available".to_string(), + Availability::OptimizedOut => "optimized out".to_string(), + Availability::NotInScope => "not in scope".to_string(), + Availability::Unsupported(reason) => format!("unsupported: {reason:?}"), + Availability::Requires(requirement) => format!("requires: {requirement:?}"), + Availability::Ambiguous(reason) => format!("ambiguous: {reason:?}"), + } +} + +fn lowering_label(kind: &VariableLoweringKind) -> &'static str { + match kind { + VariableLoweringKind::DirectValue => "direct value", + VariableLoweringKind::UserMemoryRead => "user memory read", + VariableLoweringKind::Composite => "composite", + VariableLoweringKind::Unavailable => "unavailable", + } +} diff --git a/ghostscope/src/cli/mod.rs b/ghostscope/src/cli/mod.rs index 1f672a10..eb549989 100644 --- a/ghostscope/src/cli/mod.rs +++ b/ghostscope/src/cli/mod.rs @@ -2,6 +2,7 @@ mod color; mod docs; +mod dry_run; mod loading_reporter; pub mod script_output; pub mod script_runtime; diff --git a/ghostscope/src/cli/script_runtime.rs b/ghostscope/src/cli/script_runtime.rs index 945a5426..ebc5d5dc 100644 --- a/ghostscope/src/cli/script_runtime.rs +++ b/ghostscope/src/cli/script_runtime.rs @@ -295,20 +295,60 @@ async fn run_cli_with_session( // Step 6: Build compile options from merged config let binary_path_hint = crate::util::derive_binary_path_hint(&session); - let compile_options = config.get_compile_options( + let mut compile_options = config.get_compile_options( config.should_save_llvm_ir, config.should_save_ebpf, config.should_save_ast, binary_path_hint, ); + if config.dry_run { + compile_options.save_llvm_ir = false; + compile_options.save_ebpf = false; + compile_options.save_ast = false; + } - // Step 7: Compile and load script with graceful error handling + // Step 7: Compile the script and either report the plan or attach uprobes if show_cli_status { eprintln!( "{}", - stderr_colors.yellow(stderr_colors.bold("Compiling script...")) + stderr_colors.yellow(stderr_colors.bold(if config.dry_run { + "Compiling script for dry run..." + } else { + "Compiling script..." + })) + ); + } + + if config.dry_run { + let compilation_result = match crate::script::compile_script_for_cli( + &script_content, + &mut session, + &compile_options, + ) { + Ok(result) => result, + Err(e) => { + error!("Failed to compile dry-run script: {:#}", e); + return Err(e); + } + }; + + if show_cli_status { + eprintln!( + "{}", + stderr_colors.green("Dry run complete; no uprobes attached.") + ); + } + crate::cli::dry_run::print_dry_run_report( + &compilation_result, + session.process_analyzer.as_ref(), + &compile_options.runtime_capabilities, + crate::cli::dry_run::DryRunReportOptions { + details: config.dry_run_details, + }, ); + return Ok(()); } + if let Err(e) = crate::script::compile_and_load_script_for_cli( &script_content, &mut session, @@ -574,6 +614,8 @@ mod tests { script_color_mode: CliColorMode::Auto, script_output_events_per_sec: 10_000, tui_mode: false, + dry_run: false, + dry_run_details: false, should_save_llvm_ir: false, should_save_ebpf: false, should_save_ast: false, diff --git a/ghostscope/src/config/args.rs b/ghostscope/src/config/args.rs index ec81cdb6..ccb8d25c 100644 --- a/ghostscope/src/config/args.rs +++ b/ghostscope/src/config/args.rs @@ -234,6 +234,14 @@ pub struct Args { #[arg(long, value_name = "MODE", value_enum)] pub script_output: Option, + /// Compile and resolve trace targets without attaching uprobes + #[arg(long, action = clap::ArgAction::SetTrue)] + pub dry_run: bool, + + /// Include detailed target and variable diagnostics in dry-run output + #[arg(long = "dry-run-details", requires = "dry_run", action = clap::ArgAction::SetTrue)] + pub dry_run_details: bool, + /// Enable interactive DWARF/script/attach status prompts on stderr #[arg(long, action = clap::ArgAction::SetTrue)] pub status: bool, @@ -325,6 +333,8 @@ pub struct ParsedArgs { pub script_file: Option, pub pid: Option, pub tui_mode: bool, + pub dry_run: bool, + pub dry_run_details: bool, pub script_output: Option, pub status_enabled: bool, pub has_explicit_status_flag: bool, @@ -475,6 +485,8 @@ impl Args { let should_save_ebpf = Self::should_save_ebpf(&parsed); let should_save_ast = Self::should_save_ast(&parsed); let tui_mode = Self::determine_tui_mode(&parsed); + let dry_run = parsed.dry_run; + let dry_run_details = parsed.dry_run_details; let target_path = Self::resolve_target_path(&parsed); let (status_enabled, has_explicit_status_flag) = Self::determine_status_config(&parsed); let ( @@ -502,6 +514,8 @@ impl Args { script_file: parsed.script_file, pid: parsed.pid, tui_mode, + dry_run, + dry_run_details, script_output: parsed.script_output, status_enabled, has_explicit_status_flag, @@ -562,6 +576,10 @@ impl Args { /// Determine whether to start in TUI mode fn determine_tui_mode(parsed: &Args) -> bool { + if parsed.dry_run { + return false; + } + // Explicit --tui flag takes precedence if parsed.tui { return true; @@ -689,6 +707,8 @@ impl Args { mod tests { use std::path::PathBuf; + use clap::Parser; + use super::{ Args, BpffsCommand, BpffsPruneArgs, ParsedCommand, ScriptOutputMode, ScriptTimestampFormat, }; @@ -861,6 +881,44 @@ mod tests { } } + #[test] + fn parses_dry_run_details_flag() { + let parsed = Args::parse_args_from(vec![ + "ghostscope".to_string(), + "--pid".to_string(), + "1234".to_string(), + "--script".to_string(), + "trace main { print 1; }".to_string(), + "--dry-run".to_string(), + "--dry-run-details".to_string(), + ]); + + match parsed { + ParsedCommand::Trace(args) => { + assert!(args.dry_run); + assert!(args.dry_run_details); + assert!(!args.tui_mode); + } + other => panic!("unexpected parse result: {other:?}"), + } + } + + #[test] + fn dry_run_details_requires_dry_run() { + let err = Args::try_parse_from(vec![ + "ghostscope", + "--pid", + "1234", + "--script", + "trace main { print 1; }", + "--dry-run-details", + ]) + .unwrap_err() + .to_string(); + + assert!(err.contains("--dry-run")); + } + #[test] fn parses_debuginfod_flags() { let parsed = Args::parse_args_from(vec![ diff --git a/ghostscope/src/config/user.rs b/ghostscope/src/config/user.rs index 2d640445..9785e635 100644 --- a/ghostscope/src/config/user.rs +++ b/ghostscope/src/config/user.rs @@ -31,6 +31,8 @@ pub struct UserConfig { pub script_color_mode: CliColorMode, pub script_output_events_per_sec: u64, pub tui_mode: bool, + pub dry_run: bool, + pub dry_run_details: bool, // File saving options pub should_save_llvm_ir: bool, @@ -170,6 +172,8 @@ impl UserConfig { .script_output_events_per_sec .unwrap_or(config.script.output_events_per_sec), tui_mode, + dry_run: args.dry_run, + dry_run_details: args.dry_run_details, should_save_llvm_ir, should_save_ebpf, should_save_ast, @@ -278,6 +282,12 @@ impl UserConfig { } } + if self.dry_run && self.script.is_none() && self.script_file.is_none() { + return Err(anyhow::anyhow!( + "--dry-run requires --script or --script-file because there is no trace plan to resolve" + )); + } + if let Some(debug_file) = &self.debug_file { if !debug_file.exists() { return Err(anyhow::anyhow!( diff --git a/ghostscope/src/core/session.rs b/ghostscope/src/core/session.rs index ddcc23f0..4833db6a 100644 --- a/ghostscope/src/core/session.rs +++ b/ghostscope/src/core/session.rs @@ -114,7 +114,9 @@ impl GhostSession { // Start sysmon for standalone -t only. Combined -t -p uses the PID for // concrete process mappings and does not need system-wide lifecycle tracking. - if s.proc_pid().is_none() && s.target_binary.is_some() { + if config.dry_run { + info!("Sysmon not started (dry-run mode)"); + } else if s.proc_pid().is_none() && s.target_binary.is_some() { let tpath = PathBuf::from(s.target_binary.as_ref().unwrap()); if config.ebpf_config.enable_sysmon_for_target { let cfg = SysmonConfig { @@ -449,6 +451,8 @@ mod tests { script: None, script_file: None, tui_mode: false, + dry_run: false, + dry_run_details: false, script_output: None, status_enabled: true, has_explicit_status_flag: false, diff --git a/ghostscope/src/main.rs b/ghostscope/src/main.rs index f820f4c7..e8a637fd 100644 --- a/ghostscope/src/main.rs +++ b/ghostscope/src/main.rs @@ -49,13 +49,13 @@ async fn main() -> Result<()> { info!("{}", user_config.config_source_message()); - // Ensure we have the privileges needed for eBPF interaction + // Dry-run does not attach uprobes, but it still validates the same eBPF + // privileges and kernel capabilities as a real run. crate::util::ensure_privileges(); - - let kernel_caps = ghostscope_loader::KernelCapabilities::detect_for_startup( + let kernel_caps = *ghostscope_loader::KernelCapabilities::detect_for_startup( user_config.ebpf_config.force_perf_event_array, )?; - let resolved_config = config::ResolvedConfig::resolve(user_config, kernel_caps)?; + let resolved_config = config::ResolvedConfig::resolve(user_config, &kernel_caps)?; // Best-effort cleanup for this process's bpffs pins on graceful shutdown and panic unwind. let _pinned_maps_cleanup = crate::util::PinnedMapsCleanupGuard::new(); diff --git a/ghostscope/src/script/compiler.rs b/ghostscope/src/script/compiler.rs index e0b319fe..2f7d74b0 100644 --- a/ghostscope/src/script/compiler.rs +++ b/ghostscope/src/script/compiler.rs @@ -534,12 +534,12 @@ pub async fn compile_and_load_script_for_tui( }) } -/// Compile and load script for command line mode using session.command_loaders -pub async fn compile_and_load_script_for_cli( +/// Compile a script for command line mode without attaching uprobes. +pub fn compile_script_for_cli( script: &str, session: &mut GhostSession, compile_options: &ghostscope_compiler::CompileOptions, -) -> Result<()> { +) -> Result { let fallback_host_pid = session.host_pid(); info!("Starting unified script compilation with DWARF integration..."); @@ -590,6 +590,17 @@ pub async fn compile_and_load_script_for_cli( } } + Ok(compilation_result) +} + +/// Compile and load script for command line mode using session.command_loaders +pub async fn compile_and_load_script_for_cli( + script: &str, + session: &mut GhostSession, + compile_options: &ghostscope_compiler::CompileOptions, +) -> Result<()> { + let compilation_result = compile_script_for_cli(script, session, compile_options)?; + // Ensure -p offsets are cached once per session if let Some(proc_pid) = session.proc_pid() { let result = { diff --git a/ghostscope/src/script/mod.rs b/ghostscope/src/script/mod.rs index 80623ddc..6efebfa4 100644 --- a/ghostscope/src/script/mod.rs +++ b/ghostscope/src/script/mod.rs @@ -3,4 +3,6 @@ pub mod compiler; // Re-export main functions for convenience -pub use compiler::{compile_and_load_script_for_cli, compile_and_load_script_for_tui}; +pub use compiler::{ + compile_and_load_script_for_cli, compile_and_load_script_for_tui, compile_script_for_cli, +};