diff --git a/e2e-tests/tests/dwarf_index_regressions.rs b/e2e-tests/tests/dwarf_index_regressions.rs
index 9a0ee345..c1ff8b28 100644
--- a/e2e-tests/tests/dwarf_index_regressions.rs
+++ b/e2e-tests/tests/dwarf_index_regressions.rs
@@ -359,6 +359,114 @@ fn test_read_uleb128_rejects_values_that_overflow_u64() {
);
}
+#[tokio::test]
+async fn test_dwarf_lookup_errors_do_not_fall_back_to_globals() -> anyhow::Result<()> {
+ init();
+ let Some(cc) = preferred_c_compiler() else {
+ eprintln!("Skipping DWARF lookup regression: no C compiler is available");
+ return Ok(());
+ };
+ let dir = tempfile::tempdir()?;
+ let source = dir.path().join("lookup.c");
+ let binary = dir.path().join("lookup");
+ fs::write(
+ &source,
+ "int state = 11;\n\
+ struct { int field; } cfg = {13};\n\
+ int probe(void) { return state + cfg.field; }\n\
+ int local_probe(int state) { return state; }\n\
+ int main(void) { return probe() + local_probe(7); }\n",
+ )?;
+ run_command(
+ StdCommand::new(cc)
+ .args(["-g", "-O0"])
+ .arg(&source)
+ .arg("-o")
+ .arg(&binary),
+ "compile DWARF lookup fixture",
+ )?;
+
+ let analyzer = ghostscope_dwarf::DwarfAnalyzer::from_exec_path(&binary).await?;
+ let probe = analyzer.lookup_function_addresses("probe").remove(0);
+ let probe_context = analyzer.resolve_pc(&probe)?;
+ assert!(analyzer
+ .plan_variable_by_name(&probe_context, "state")?
+ .is_none());
+ let local_probe = analyzer.lookup_function_addresses("local_probe").remove(0);
+ let local_context = analyzer.resolve_pc(&local_probe)?;
+ assert!(analyzer
+ .plan_variable_by_name(&local_context, "state")?
+ .is_some());
+
+ // The CRT entry point is executable but has no local DWARF scope in this
+ // fixture. Its failed local query must not bind the file-scope `state`.
+ let bytes = fs::read(&binary)?;
+ let object = object::File::parse(bytes.as_slice())?;
+ let entry_pc = object
+ .symbols()
+ .find(|symbol| symbol.name() == Ok("_start"))
+ .context("fixture has no CRT entry point")?
+ .address();
+ let entry = ghostscope_dwarf::ModuleAddress::new(binary.clone(), entry_pc);
+ let entry_context = analyzer.resolve_pc(&entry)?;
+ let error = analyzer
+ .plan_variable_by_name(&entry_context, "state")
+ .expect_err("a failed scope query must not report a missing binding");
+ assert!(matches!(
+ error,
+ ghostscope_dwarf::VariableLookupError::QueryFailed(_)
+ ));
+
+ let options = ghostscope_compiler::CompileOptions {
+ binary_path_hint: Some(binary.to_string_lossy().into_owned()),
+ ..Default::default()
+ };
+ for (expression, base, path) in [
+ (
+ "state",
+ "state",
+ ghostscope_dwarf::VariableAccessPath::default(),
+ ),
+ (
+ "cfg.field",
+ "cfg",
+ ghostscope_dwarf::VariableAccessPath::fields(["field"]),
+ ),
+ ] {
+ // Prove that a global fallback would succeed, so the failure below
+ // specifically exercises the compiler's local-query error boundary.
+ assert!(analyzer
+ .plan_global_access_read_plan_at_address(&entry, base, &path)?
+ .is_some());
+
+ let valid = ghostscope_compiler::compile_script(
+ &format!("trace probe {{ print {expression}; }}"),
+ &analyzer,
+ None,
+ Some(1),
+ &options,
+ )?;
+ assert_eq!(valid.uprobe_configs.len(), 1);
+ assert!(valid.failed_targets.is_empty());
+
+ let invalid = ghostscope_compiler::compile_script(
+ &format!("trace 0x{entry_pc:x} {{ print {expression}; }}"),
+ &analyzer,
+ None,
+ Some(1),
+ &options,
+ )
+ .expect_err("a local-query failure must reject scalar and member global fallback");
+ assert!(
+ invalid
+ .to_string()
+ .contains("StrictIndex: no function found"),
+ "{expression}: {invalid}"
+ );
+ }
+ Ok(())
+}
+
#[tokio::test]
#[serial_test::serial]
async fn test_gnu_pubnames_resolve_symbols_lazily_and_reject_corruption() -> anyhow::Result<()> {
diff --git a/ghostscope-compiler/src/ebpf/dwarf_bridge.rs b/ghostscope-compiler/src/ebpf/dwarf_bridge.rs
index 58af23cd..a2af60f4 100644
--- a/ghostscope-compiler/src/ebpf/dwarf_bridge.rs
+++ b/ghostscope-compiler/src/ebpf/dwarf_bridge.rs
@@ -1481,46 +1481,18 @@ impl<'ctx, 'dw> EbpfContext<'ctx, 'dw> {
let module_address =
ghostscope_dwarf::ModuleAddress::new(prefer_module.clone(), pc_address);
- let pc_plan = match analyzer.resolve_pc(&module_address) {
- Ok(pc_context) => match analyzer.plan_variable_by_name(&pc_context, var_name) {
- Ok(Some(plan)) => {
- debug!("Found DWARF variable '{}' via PC variable plan", var_name);
- Some(plan)
- }
- Ok(None) => {
- debug!(
- "Variable '{}' not found in PC variable plan; trying global read plan",
- var_name
- );
- None
- }
- Err(err) => {
- let message = err.to_string();
- if message.starts_with("Ambiguous variable")
- || message.starts_with("Unavailable variable")
- {
- return Err(CodeGenError::DwarfError(message));
- }
- debug!(
- "PC variable plan lookup error for '{}': {message}; trying global read plan",
- var_name
- );
- None
- }
- },
- Err(err) => {
- debug!(
- "PC context resolution failed for '{}': {err}; trying global read plan",
- var_name
- );
- None
- }
- };
-
- if pc_plan.is_some() {
- return Ok(pc_plan);
+ let pc_context = analyzer
+ .resolve_pc(&module_address)
+ .map_err(|err| CodeGenError::DwarfError(err.to_string()))?;
+ if let Some(plan) = analyzer
+ .plan_variable_by_name(&pc_context, var_name)
+ .map_err(|err| CodeGenError::DwarfError(err.to_string()))?
+ {
+ debug!("Found DWARF variable '{}' via PC variable plan", var_name);
+ return Ok(Some(plan));
}
+ // Only a successful local lookup with no binding permits global fallback.
if let Some((_global_module, plan)) = analyzer
.plan_global_access_read_plan_at_address(
&module_address,
@@ -1587,28 +1559,15 @@ impl<'ctx, 'dw> EbpfContext<'ctx, 'dw> {
let module_address =
ghostscope_dwarf::ModuleAddress::new(prefer_module.clone(), pc_address);
- match analyzer.resolve_pc(&module_address) {
- Ok(pc_context) => {
- match analyzer.plan_variable_access_by_name(&pc_context, base_name, access_path) {
- Ok(Some(plan)) => {
- debug!("Found DWARF access '{path_text}' via PC variable access plan");
- return Ok(Some(plan));
- }
- Ok(None) => {}
- Err(err) => {
- let message = err.to_string();
- debug!(
- "PC variable access plan lookup failed for '{path_text}': {message}"
- );
- return Err(CodeGenError::DwarfError(message));
- }
- }
- }
- Err(err) => {
- debug!(
- "PC context resolution failed for '{path_text}': {err}; trying global read plan"
- );
- }
+ let pc_context = analyzer
+ .resolve_pc(&module_address)
+ .map_err(|err| CodeGenError::DwarfError(err.to_string()))?;
+ if let Some(plan) = analyzer
+ .plan_variable_access_by_name(&pc_context, base_name, access_path)
+ .map_err(|err| CodeGenError::DwarfError(err.to_string()))?
+ {
+ debug!("Found DWARF access '{path_text}' via PC variable access plan");
+ return Ok(Some(plan));
}
if let Some((_module_path, plan)) = analyzer
diff --git a/ghostscope-dwarf/src/analyzer/plan_pc.rs b/ghostscope-dwarf/src/analyzer/plan_pc.rs
index d05d7c4c..94dcb4cc 100644
--- a/ghostscope-dwarf/src/analyzer/plan_pc.rs
+++ b/ghostscope-dwarf/src/analyzer/plan_pc.rs
@@ -3,7 +3,8 @@ use crate::{
core::{demangle::RustSymbolHashDisplay, ModuleAddress, Provenance, Result},
semantics::{
AddressSpaceInfo, FunctionParameter, PcContext, PcLineInfo, PcRange, VariableAccessPath,
- VariableAccessSegment, VariableReadPlan, VisibleVariable, VisibleVariablesResult,
+ VariableAccessSegment, VariableLookupError, VariableReadPlan, VisibleVariable,
+ VisibleVariablesResult,
},
};
use std::path::Path;
@@ -240,11 +241,13 @@ impl DwarfAnalyzer {
/// Plan a visible variable by source name at a previously resolved PC context.
///
/// Exact names are preferred over producer-synthesized names like `name@...`.
+ /// `Ok(None)` means no local binding was found and allows a global lookup.
+ /// Unavailable or ambiguous bindings and failed queries must not fall back.
pub fn plan_variable_by_name(
&self,
ctx: &PcContext,
name: &str,
- ) -> Result