rust-lang / rust

Empowering everyone to build reliable and efficient software.
https://www.rust-lang.org
Other
97.61k stars 12.62k forks source link

Rustc adds line-number information for unhittable panic handlers #55352

Open bossmc opened 5 years ago

bossmc commented 5 years ago

First off, root issue I was investigating was kcov producing bad coverage information for Rust binaries. I've worked out why kcov is producing the results it is and they're "correct" given what Rustc is doing. I'm not sure where the fix (if any) needs to be made, but I'm starting with Rustc as kcov's strategy looks sound.

Consider the following code:

struct Person {
  name: String,
  age: u32,
}

fn get_age() -> u32 {
  42
}

fn create_bob() -> Person {
  Person {
    name: "Bob".to_string(),
    age: get_age(),
  }  // Uncovered
}

fn main() {
  let b = create_bob();
}

Kcov will mark the "Uncovered" line as unhit. This is surprising, as that line was definitely passed during the execution of the program. Lines can be omitted from kcov coverage (with // KCOV_EXCL_LINE) but this leads to hundreds of those markers scattered around the code, potentially hiding real coverage lapses/lowering maintainability of the codebase.


Kcov determines coverage by looking at the .debug_lines section of the binary, which contains a mapping from address in the binary to line of code. It then sets a break point at every listed address before running the program. Each time a break point is hit, kcov marks the associated line of code as hit, and clears the breakpoint (as hitting it again tells us nothing, and breakpoints are slow). After the process completes, any lines remaining were not hit.

This means that kcov's "was this line hit" logic is pretty solid, assuming the .debug_lines section is accurate.


When Rust generates a block that calls any function after creating any binding to a type with a Drop implementation, it also generates a unwind-cleanup block to call that drop() in the event of the function panic-ing. In the above case, the generated code is something like (assuming Rust had exceptions):

fn create_bob() -> Person {
  let name = "Bob".to_string();
  try {
    let age = get_age();
  } catch e {
    drop(name);
    throw e;
  }
  Person {
    name,
    age,
  }
}

This makes sense, if there is a panic (which might get handled up the stack somewhere) we should delete bindings that are memory-safe (yay Rust!) to free up memory that's not going to be accessible any more.


Unfortunately:

In a release build, the cleanup handler is stripped because LLVM notices that there's no way for anything in the try block to panic, so the handler is not needed. In a debug build, it doesn't do this optimisation and leaves genuinely unhittable code in the binary, but associates it with a line of code, causing many false positives in kcov's output (especially as kcov uses debug builds to prevent dead-code elimination removing untested code).


What to do differently? I'm not sure, but here are some ideas:

bossmc commented 5 years ago

Simple repro in https://github.com/bossmc/kcov-rust-issue.

Reproduce with cargo kcov (from https://github.com/kennytm/cargo-kcov). Note that the closing brace is marked as uncovered unless the second test is enabled.

pnkfelix commented 2 years ago

This still reproduces.

I want to dig in with a little private investigation to understand the scenarios where the cleanup path is marked as unreached, and to know how one should be expected to avoid such marking in cases like this (e.g. should we require a #[no_panic] attribute on the function signature of fn get_age to signal that tools can assume it doesn't panic?)

I also want to understand the scenarios where people want to know about code paths that were silently injected by the compiler. I do believe there are cases where people want to know about that, but I also think the majority of programmers are trying to avoid actively thinking about cleanup paths, and for the most part we want to encourage that...

bossmc commented 2 years ago

There are two "markings" at play here:

  1. The compiler marking addresses with debug information (mainly intended for consumption by gdb/lldb/etc.
  2. KCov marking lines of source code as hit/unhit

Which are you asking about? Are you exploring if Rustc can detect the unhittable landing pads and not emit debug information for them? Or if kcov can tell if a given debug info line corresponds to a landing pad and therefore not report it?

pnkfelix commented 2 years ago

I'm asking about the expected/desired experience for an end-developer. I expect some people are going to want to test the control-flow paths that correspond to panics for certain library routines. But I also expect some set of people to treat some panic paths as something they do not attempt to test (because, for example, they are truly unreachable -- as is the case in the situation outlined in this issue).

What is the right way to serve both groups? Is it to ask people to add certain attributes to their source code (and then rustc incorporates those attributes into the metadata that it generates for kcov)? Or should we expect the source code in both of these scenarios to remain unchanged?

(To be clear: @bossmc 's original description included bullet points that explore this question with ideas of paths to follow. I just want to start from a higher level: To ask what the ideal experience should be in the two scenarios I outlined, and then try to work backwards from there.)