Panics opsporen met Call Graph Analyzer
We krijgen vaak de vraag: "Hoe kan ik panics in mijn Rust-code elimineren?" Er zijn verschillende pogingen gedaan om dit probleem aan te pakken. In het kader van het Open Device Partnership en in samenwerking met Microsoft hebben wij de Call Graph Analyzer (CGA) ontwikkeld en uitgebracht. Dit is een tool om de aanroepgrafiek (callgraph) te extraheren uit monomorfiseerde MIR. Deze aanroepgrafiek kan worden gebruikt om op te sporen welke functies in het gecompileerde binary kunnen panic-en.
Wanneer je bijvoorbeeld cargo cga check uitvoert op je code, krijg je het volgende:
error: call to method `core::num::<impl u64>::strict_sub` which can panic
--> src/main.rs:18:17
|
18 | let _ = 2u64.strict_sub(u64::MAX);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In dit blogbericht bespreken we waarom deze nieuwe aanpak zowel beter als slechter is dan bestaande methoden voor het vinden van panics.
Waarom panics elimineren?
Voordat we de Call Graph Analyzer bespreken, moeten we eerst overwegen waarom panics nuttig zijn en in welke gevallen het voordelig kan zijn om ze te minimaliseren of zelfs volledig te elimineren. Dit onderwerp is uitgebreid onderzocht door anderen [1][2][3][4], maar het is nuttig om hier een samenvatting te geven. Laten we beginnen met een korte beschrijving:
Rust biedt een mechanisme om te voorkomen dat een functie normaal terugkeert, en in plaats daarvan te "panic-en", wat een reactie is op een foutconditie die doorgaans niet herstelbaar wordt geacht...
— The Rust Reference - Panic
Deze onherstelbare foutcondities kunnen worden veroorzaakt door een bug in de implementatie van een functie, of ze vormen een schending van de invoercondities voor een functie (dus een bug in de functie zelf of een fout van de aanroeper van die functie).
Een voorbeeld is het aanroepen van 1.strict_add(u32::MAX), wat zou resulteren in een panic omdat de aanroeper ongeldige argumenten aan de operatie doorgeeft. Evenzo zal een normale optelling 1 + u32::MAX in Rust panic-en als overflow-checks zijn ingeschakeld.
In plaats van te panic-en, kunnen foutcondities worden afgehandeld door:
- Ze herstelbaar te maken door de functie een
Result<T, E>ofOption<T>te laten retourneren. Voor optellingen wordt dit geboden dooru32::checked_add. - De functie impliciet of stilzwijgend te laten falen en dit gedrag te beschrijven in de documentatie van de functie. Rekenkundige operaties kunnen bijvoorbeeld "wrappen" met
u32::wrapping_add. - Een functie te maken die nooit terugkeert, bijvoorbeeld door een oneindige lus
{}. Soms wordt deze aanpak per ongeluk gebruikt bij een retry-schema zonder timeout of maximaal aantal iteraties.
Het streven naar nul panics is echter erg moeilijk en vaak ondoelmatig: code is zelden vrij van aannames. Overal Result<T, AssumptionViolated> retourneren verschuift de last alleen maar omhoog in de call stack, totdat je uiteindelijk alsnog afbreekt bij de root. Dit levert in essentie hetzelfde gedrag op als wanneer je zou panic-en.
Het is echter nuttig om panics expliciet te maken. Het is beter om op de hoogte te zijn van je panic-paden en hierover na te kunnen redeneren, dan om per ongeluk panics in te bouwen en te negeren dat ze bestaan. Regressieanalyse op je panic-paden is ook zeer nuttig; zo word je op de hoogte gesteld van elke nieuwe panic-route die in je code wordt geïntroduceerd.
Dat gezegd hebbende, in de volgende gevallen kan het gerechtvaardigd zijn om sommige panics te elimineren:
1. Beperkte codeomvang (Embedded devices)
In contexten waar codeomvang echt uitmaakt, zoals op embedded devices. Panics resulteren in een relatief grote hoeveelheid code vanwege de benodigde strings en de bijbehorende code om deze strings te formatteren. In plaats daarvan kunnen we:
defmt::panicgebruiken om deze string-bloat te verminderen. Dit vereist dat alle panics in de macro worden gewrapt. Voor bibliotheekafhankelijkheden gebeurt dit doorgaans alleen wanneer de bibliotheek specifiek bedoeld is voor gebruik op een embedded platform. Defmt wordt niet gebruikt in andere bibliotheken, inclusief de standaardbibliotheek van Rust.-Cpanic=immediate-abortgebruiken om te voorkomen dat er überhaupt panics worden uitgegeven en de applicatie in plaats daarvan direct te laten crashen. Dit laat echter weinig informatie achter die gebruikt kan worden voor debugging.
2. Kritieke codepaden
In kritieke codepaden die absoluut nooit mogen falen, bijvoorbeeld:
- Een extern toegankelijke service die een Denial of Service (DOS) aanval moet voorkomen die de service laat crashen. Ongeldige invoer zou in plaats daarvan moeten resulteren in een foutmelding voor de gebruiker.
- Het firmware-updatepad op een embedded device, inclusief de bootloader.
3. Panic-handling code
In de panic-handling code zelf. In Rust veroorzaakt het unwinden van een panic in een implementatie van Drop een zogenaamde 'double-drop' en kan dit ervoor zorgen dat het programma wordt afgebroken. Ten tweede is een panic in een aangepaste #[panic_handler] niet toegestaan, maar het is onduidelijk wat er dan zou moeten gebeuren.
Het streven naar geen enkele onherstelbare foutconditie is in de praktijk extreem moeilijk. Over het algemeen is het beter om recovery-mechanismen op het hoogste niveau te implementeren. In embedded systemen zou dit inhouden: het gebruik van een Watchdog timer die het apparaat reset als het vastloopt, en een recovery-image om het apparaat naar een werkende staat te herstellen. Op een host-machine zou dit kunnen betekenen dat unwinding panics worden opgevangen met catch_unwind. Let op dat dit laatste niet waterdicht is en gepaard gaat met enkele kanttekeningen. Extern toegankelijke services zouden redundant moeten zijn en snel kunnen herstellen.
Concluderend: het is nuttig om onbedoelde panics op te sporen.
Bestaande methoden (Prior Art)
Momenteel zijn er verschillende manieren om aanroepgrafieken te extraheren of panics te detecteren:
- rustig (ELF debug info analyse)
- findpanics (ELF debug info analyse)
- cargo-callgraph (Rust HIR analyzer)
- cargo-callgraph (rust-analyzer based exporter)
- charon (Rust MIR exporter)
- no-panic (Linker-based panic detection)
- dont_panic (Linker-based panic detection)
Linker-gebaseerde detectiemethoden werken door een fout in de linker te triggeren wanneer deze probeert een speciale panic-handler te linken. Deze aanpak werkt alleen als er geen enkele panic in je binary is opgenomen. Daarnaast werkt het alleen op binaries en niet op libraries.
ELF debug info analyse kijkt naar de DWARF debug info in ELF-artefacten, zoals binaries en Rust-libraries die zijn gecompileerd tot objectbestanden. Dit is beperkt in de informatie die het kan bieden: de panic-machinery kan volledig geïnlined zijn of verdwijnen tijdens de linker-fase.
Ten slotte zijn er de HIR-, MIR- en rust-analyzer-gebaseerde benaderingen. Deze gebruiken de verschillende Intermediate Representations (IR's) van je code die door de compiler (en rust-analyzer) worden gegenereerd. Theoretisch werken deze benaderingen op elke Rust crate.
De HIR-gebaseerde analyse kan alleen de code in je eigen crate analyseren. Het kan controleren of je expliciet panic! schrijft in je eigen crate, maar is onwetend over code in je dependencies, zoals de standaardbibliotheek. Daarom kan het geen panics detecteren die binnen dependencies voorkomen. De compiler kan ook assertions invoegen, zoals bound checks, die pas heel laat in het code-generatieproces een aanroep naar panic worden.
De MIR-gebaseerde analyse is ideaal, omdat deze theoretisch alle code uit dependencies kan zien voordat deze wordt geoptimaliseerd, inclusief panics die hun oorsprong vinden in de standaardbibliotheek. Daarom maakt de Call Graph Analyzer gebruik van deze aanpak. Het enige nadeel is dat deze analyse wordt uitgevoerd voordat LLVM onbereikbare code kan verwijderen, wat leidt tot enkele fout-positieven.
De rust-analyzer aanpak is iets anders, omdat deze helemaal niet afhankelijk is van de compiler. Rust-analyzer heeft de Rust-HIR-MIR pipeline van de compiler gedupliceerd en aangepast voor hun eigen proces. Deze aparte pipeline is erg snel, kan in het geheugen worden opgeslagen en ondersteunt het bijwerken van de status terwijl je actief je Rust-code schrijft. Echter, het kan niet altijd code analyseren die rustc wel gewoon compileert. Met name complexe proc_macros vormen vaak een probleem. Het voordeel is dat het niet afhankelijk is van de Rust-compiler.
Hoe CGA werkt
Voor ons gebruik hadden we een methode nodig die:
- Alle panics detecteert;
- Configureerbare delen van de binary toestaat om te panic-en;
- Compatibel is met async Rust (aangezien alle async blocks een impliciet panic-pad hebben);
- Goed werkt met Embedded Rust.
We hebben gekozen voor een MIR-gebaseerde callgraph exporter: de Call Graph Analyzer (CGA).
rustc-driver
CGA extraheert de callgraph direct uit MIR als een rustc-driver. Clippy is op een vergelijkbare manier opgebouwd. Hierdoor is CGA afhankelijk van een specifieke versie van de Rust-compiler, wat leidt tot het eerste nadeel:
Waarschuwing: CGA compileert je crate met een specifieke (waarschijnlijk andere) versie van de rustc-compiler, de standaardbibliotheek en potentieel andere compiler-flags. Hierdoor krijg je niet exact dezelfde callgraph als die in je uiteindelijke binary zit.
Omdat CGA een specifieke (nightly) versie van rustc als afhankelijkheid heeft via rustc-private, kan het niet worden geïnstalleerd als een crate via crates.io. In plaats daarvan moet de repository lokaal worden uitgecheckt, waarna cargo install --path . moet worden uitgevoerd.
Als rustc-driver heeft de CGA-binary dezelfde API als rustc zelf. Je kunt CGA via cargo uitvoeren met cargo cga check. Intern draait rustc alsof je cargo check zou uitvoeren, maar CGA doet extra werk om de callgraph te verzamelen. Voor een typische crate betekent dit dat compilatie met CGA ongeveer 2x tot 3x zo traag is als cargo check.
MIR-traversal
Wanneer CGA alle compiler-passes heeft doorlopen die normaal deel uitmaken van cargo check, dwingt het de compiler om alle zogenaamde roots of entrypoints van je crate te leveren. Dit zijn alle delen van je crate die van buitenaf toegankelijk zijn:
fn mainen varianten daarvan;- Het
_startsymbool van de taal; - Functies die gemarkeerd zijn als
externen een stabiele naam hebben vianomangleofexportname, inclusief interrupt handlers voor Embedded Rust.
We zijn expliciet niet geïnteresseerd in functies die worden gecompileerd als onderdeel van const blocks, aangezien alleen de resultaten van deze berekeningen in de uiteindelijke binary terechtkomen.
Vanaf deze roots voeren we een bereikbaarheidsanalyse uit door de MIR-boom te doorlopen. Elke keer dat een functie wordt aangeroepen, wordt ook die functie verkend. Ongebruikte functies worden dus niet bezocht. In Rust worden functies met generieke argumenten monomorfiseerde wanneer ze worden aangeroepen met concrete types. Daarom verzamelen we niet alleen de aangeroepen functies, maar de concrete instantiatie van elke functie, omdat de implementaties sterk kunnen verschillen afhankelijk van de type-argumenten.
Dit proces wordt complex bij:
- dyn objects: Het is onduidelijk welke trait-implementatie daadwerkelijk wordt aangeroepen. CGA neemt aan dat alle vtable-entries voor die functie worden aangeroepen.
- functiepointers: We kunnen niet altijd redeneren over wat er met deze pointers gebeurt. We nemen daarom aan dat elke aangemaakte functiepointer op een later moment zal worden aangeroepen.
- async blocks: Elk async block heeft impliciet een panic-pad.
Extracting a graph
Na het doorlopen van de MIR-boom van je crate en dependencies, hebben we een collectie callgraphs. Deze kunnen worden geëxporteerd naar JSON of GraphViz .dot bestanden, of gecontroleerd worden op panics of blacklisted functies.
Bij extractie worden alle callgraphs in je /target folder geplaatst als <crate>.callgraph.json. Deze worden samengevoegd en geëxporteerd via: cargo cga extract -Tdot -o callgraph.dot
Hieronder een voorbeeld van hoe dit werkt met dyn objects:
trait Test {
fn test(&self) {
panic!("Default impl")
}
}
struct StrictAdd;
impl Test for StrictAdd {
fn test(&self) {
let _ = 2u64.strict_add(u64::MAX);
}
}
struct StrictSub;
impl Test for StrictSub {
fn test(&self) {
let _ = 2u64.strict_sub(u64::MAX);
}
}
struct DefaultImpl;
impl Test for DefaultImpl {}
fn main() {
let object = StrictAdd;
let dyn_object: &dyn Test = &object;
dyn_object.test();
}
Dit resulteert in een grafiek met een pijl voor elke entry in de vtable.
Linting en diagnostiek
Net als Clippy kan CGA de code controleren op panic-paden via: cargo cga check
Dit analyseert de callgraph, vindt panic-paden en probeert inzicht te geven in hoe dit pad relateert aan je crate. Een lint voor de bovenstaande code ziet er zo uit:
error: call to method `core::num::<impl u64>::strict_sub` which can panic
--> src/main.rs:18:17
|
18 | let _ = 2u64.strict_sub(u64::MAX);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: 3 different panics from this location
= note: backtrace (1 of 2):
0: std::rt::panic_fmt (called)
1: core::num::imp::overflow_panic::sub (called)
2: core::num::<impl u64>::strict_sub (called) <--
3: <totallysafe::StrictSub as totallysafe::Test>::test (possible vtable resolution)
4: <dyn totallysafe::Test as totallysafe::Test>::test - virtual#3 (called)
5: totallysafe::main (entrypoint)
De diagnostiek toont de backtrace, de mogelijke vtable-resolutie en de laatste aanroep vanuit de huidige crate-context. Deze diagnostieken worden heuristisch gegenereerd; uit alle mogelijke panic-paden wordt één pad gekozen per callsite in de lokale crate via een scoring-mechanisme.
White- en blacklisting
Voor grotere crates die niet zijn ontwikkeld met het minimaliseren van panics in gedachten, is het aantal lints vaak onbeheersbaar. CGA biedt daarom configuratie-opties.
Whitelist: Als je een functie vertrouwt (ook al geeft CGA panic-paden aan), kun je deze toevoegen aan de whitelist. Elk panic-pad dat deze functie bevat, wordt genegeerd.
Blacklist: Je kunt functies op een blacklist zetten die nooit gebruikt mogen worden in je crate of dependencies. Bijvoorbeeld interacties met het bestandssysteem of netwerk.
Voorbeeld van een cga.toml configuratie:
blacklist = [
{ path = "^std::process::" },
{ path = "^std::fs::" },
{ path = "^std::sys::" }
]
Als we vervolgens dit programma invoeren:
fn test() {
let _ = std::process::Command::new("evil");
}
fn main() {
test();
}
Krijgen we de volgende foutmelding:
error: call to associated function `std::process::Command::new` which is blacklisted
--> src/main.rs:7:13
|
7 | let _ = std::process::Command::new("evil");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: 101 different blacklisted functions from this location
= note: backtrace:
0: std::process::Command::new::<&str> (called) <--
1: fail_blacklist::test (called)
2: fail_blacklist::main (entrypoint)
Let op: deze feature voorkomt niet fysiek dat een proces wordt gestart (omdat het bijvoorbeeld via assembly of externe object-bestanden kan gebeuren), maar het is een zeer interessante analyse-mogelijkheid.
Kanttekeningen (Caveats)
Fragiliteit
Het doorlopen van monomorfiseerde MIR is niet triviaal. Vtables zijn lastig te resolveren, coroutines moeten worden uitgepakt voor async-paden en er zijn impliciete assertions die uiteindelijk een panic worden. Omdat Rust MIR onstabiel is, is CGA inherent fragiel. Wanneer de toolchain wordt bijgewerkt, kan de MIR drastisch variëren, wat impact heeft op de callgraph.
Optimalisaties
Afhankelijk van je compiler-flags (met name opt-level), bevat de MIR die CGA verzamelt panics die in werkelijkheid onbereikbaar zijn.
Bijvoorbeeld: de expressie 1 + 1 heeft een impliciete overflow check. Bij opt-level=2 wordt dit door de compiler al geoptimaliseerd naar 2 voordat CGA de MIR krijgt. Bij opt-level="z" (optimalisatie voor grootte) blijft dit panic-pad echter in de MIR staan, om pas later door LLVM te worden verwijderd.
Ik adviseer om opt-level=2 te gebruiken voor CGA, zelfs als de uiteindelijke binary een andere instelling gebruikt. Dit vermindert het aantal fout-positieven zonder dat er bereikbare panics worden gemist.
Tooling verwacht rustc
CGA werkt meestal direct, maar soms niet. Dit kan komen doordat een project nog niet compileert met build-std (wat CGA vereist), of omdat build.rs verwacht dat de applicatie wordt gebouwd met de standaard rustc en niet met een rustc-driver.
Probeer het uit
Met de Call Graph Analyzer (CGA) kun je callgraphs extraheren en panics detecteren. Als je je afvraagt hoeveel panics je applicatie stiekem bevat, ook al gebruik je geen unwrap, probeer het dan eens uit!
Een speciale dank aan Jyn voor dit blogbericht en aan Ferrocene, wier werk ons sterk heeft geïnspireerd bij de ontwikkeling van de Call Graph Analyzer.
Groetjes,