#Rust

Callgraph Analysis Enables Powerful Cross-Function Lints in Rust

Rust Reporter
3 min read

A new approach to Rust linting leverages full program callgraphs to detect issues spanning multiple functions, moving beyond intra-function checks to catch resource leaks, protocol violations, and incorrect API usage patterns that standard lints miss.

Standard Rust lints like those in Clippy operate primarily within single function bodies, analyzing control flow and data flow locally. While effective for catching obvious mistakes (e.g., unused variables, obvious panics), they fundamentally cannot verify properties requiring reasoning across function boundaries—such as ensuring a resource acquired in one function is always released in another, or that a state machine protocol is followed correctly across a call chain. This limitation stems from the compiler's traditional lint pass architecture, which processes functions in isolation without global context.

The author's work describes implementing a custom lint that accesses the complete call graph of a Rust program during compilation. By integrating with rustc's query system, the lint retrieves a fully resolved call graph (including monomorphized instances and trait dispatch) after type checking but before code generation. This graph represents every possible dynamic call target as a static over-approximation, enabling the lint to perform interprocedural analysis. For example, to detect file handle leaks, the lint tracks:

  • Where fs::File::open is called (acquisition points)
  • Where file.read/write occurs (usage points)
  • Where file.drop happens implicitly via scope exit or explicit drop calls
  • Whether all paths from acquisition to program termination pass through a release point

This requires solving a reachability problem on the call graph: for each acquisition node, verify all paths lead to a release node before any potential leak point (like thread spawn or mem::forget). The implementation uses rustc's tcx.call_graph(query) to obtain the graph, then applies a custom dataflow analysis tracking resource states across call edges. Crucially, the analysis remains sound (no false negatives) by treating unresolved calls (e.g., via function pointers) as potentially reaching any node—a necessary trade-off for decidability.

A concrete example demonstrated in the post checks correct usage of the async_trait macro's locking pattern. The lint verifies that every async fn in a trait impl either:

  1. Contains no .await points (trivially safe)
  2. Or, if it contains .await, ensures the impl struct holds a MutexGuard acquired before the first .await and released after the last .await—preventing deadlocks from holding locks across await points. This property is impossible to verify without seeing the full async state machine expansion and its call relationships.

The approach incurs measurable compile-time overhead (typically 5-15% increase in clean builds for medium projects) due to call graph construction and interprocedural analysis. However, the author argues this is justified for safety-critical lints where false negatives carry significant risk. The technique relies on rustc's incremental compilation infrastructure; unchanged functions reuse prior call graph slices, making the cost primarily proportional to the size of the modified call chain.

This work highlights a growing trend in Rust tooling: leveraging the compiler's rich intermediate representations for deeper semantic checks. While not suitable for all lints (simple syntax checks remain faster intra-function), callgraph-aware analysis opens new correctness verification frontiers—particularly for protocols involving lifetimes, concurrency, and resource management where violations manifest only across function boundaries. The source code for the prototype lint is available in the author's work repository, demonstrating integration via a rustc driver plugin that hooks into the after_analysis callback.

Comments

Loading comments...