Trait Debugging 101
Traits are a pervasive language feature in Rust: Copying, printing, indexing, multiplying, and more common operations use the Rust trait system. As you use more of the language, and utilize the numerous published crates, you will inevitably encounter more traits. Popular crates in the Rust ecosystem use traits to achieve strong type safety, such as the Diesel crate that relies on traits to turn invalid SQL queries into type errors. Impressive!
Unfortunately, traits also obfuscate type errors. Compiler diagnostics become increasingly complex alongside the types and traits used. This guide demonstrates trait debugging in Rust using a new tool, Argus, developed by the Cognitive Engineering Lab at Brown University. The examples used in this tutorial are available online, we recommend you follow along at home.
Your First Web Server
Axum is a popular Rust web application framework, a great example of how traits can obfuscate type errors. We will use Axum to build a web server, and Argus to debug the trait errors; here’s our starting code.
struct LoginAttempt {
user_id: u64,
password: String,
}
fn login(attempt: LoginAttempt) -> bool {
todo!()
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/login", get(login));
let listener = TcpListener::bind("0.0.0.0:3000")
.await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Oh no, our server doesn’t type check. Surely, the error diagnostic will tell us why—
error[E0277]: the trait bound `fn(LoginAttempt) -> bool {login}: Handler<_, _>` is not satisfied
--> src/main.rs:14:49
|
14 | let app = Router::new().route("/login", get(login));
| --- ^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(LoginAttempt) -> bool {login}`
| |
| required by a bound introduced by this call
|
= help: the following other types implement trait `Handler<T, S>`:
<Layered<L, H, T, S> as Handler<T, S>>
<MethodRouter<S> as Handler<(), S>>
note: required by a bound in `axum::routing::get`
in a long-winded way the diagnostic has said “login
does not implement Handler
.” But as the authors we intended for login
to be a handler. The diagnostic hasn’t provided much specific information as to why the code doesn’t type check.
Going forward we will write {login}
to abbreviate the type of login
, fn(LoginAttempt) -> bool
, which is far too verbose to repeat over and over.
When the error diagnostic says “trait bound Bleh
is not satisfied”, it’s a great opportunity to use Argus.
Down the Search Tree
In Rust we write type definitions and trait implementations separately—we refer to trait implementations as “impl blocks.” The inner compiler component called the trait solver is responsible for answering queries like “Does {login}
implement Handler
?” such queries appear as trait bounds in the source code. The trait solver searches through the impl blocks trying to find whether or not the trait bound holds.
In this post we will be using the search tree a data structure produced by the trait solver that describes how it searched impl blocks, and why—or why not—a particular trait bound holds.
Here’s an illustrative diagram of the Axum-error search tree. Argus provides the search tree in a different format, similar to a directory tree, as you shall see further on.
--- title: Search tree produced by the Axum trait error --- graph TD root["{login}: Handler"] implRespH["impl Handler<IntoResponseHandler, S> for T\nwhere\n T: IntoResponse"] intoResp["{login}: IntoResponse"] implH["impl Handler<M, S> for F\nwhere\n F: FnOnce(T1) -> Res,\n Res: IntoResponse + Send,\n T1: FromRequest"] isFunc["{login}: FnOnce(LoginAttempt) -> bool"] boolFut["bool: IntoResponse"] loginARqst["LoginAttempt: FromRequest"] root -.-> implRespH implRespH --T = {login}--> intoResp root -.-> implH implH --F = {login}, T1 = LoginAttempt, Res = bool--> isFunc implH --Res = bool--> boolFut implH --T1 = LoginAttempt--> loginARqst class root,implRespH,intoResp,implH,boolFut,loginARqst cssFailure class isFunc cssSuccess classDef default fill:#fafafa, stroke-width:3px, text-align:left classDef cssSuccess stroke:green classDef cssFailure stroke:red linkStyle 0,1,2,4,5 stroke:red linkStyle 3 stroke:green, stroke-width:4px
We elided trivial bounds to declutter the above diagram. Don’t panic if you open the Argus panel and see some bounds not shown here.
Dotted lines represent an Or relationship between parent and child. That is, exactly one of the child blocks needs to hold—outlined in green. We see dotted lines coming from the root bound and extending to the impl blocks. Impl blocks always form an Or relationship with their parent.
Solid lines represent And relationships between parent and child. That is, every one of the child blocks needs to hold. We see solid lines coming from impl blocks and extending to the nested constraints. Constraints always form an And relationship with their parent impl block.
Traversing the tree from root to leaf is what’s referred to as “Top-Down” in the Argus extension. This view represents the full search tree, in other words, how the trait solver responded to the query.
Comparing the search tree to the error diagnostic is curious: Why did the error diagnostic mention the root bound, {login}: Handler<_, _>
, instead of a more specific failing bound at the tree leaves? The compilers job is to take the search tree and tell you something useful about why the trait bound failed—but Rust can’t tell you everything, because there’s simply too much information. So it picks the most specific failure it can, which in this case is the root node. Rust doesn’t know which of the two impl blocks you intended to use and it won’t speculate on your behalf.
We developed Argus so you can identify the specific failures that led to a particular trait error; Argus can provide more specific details on a trait error than Rust is willing to summarize in a single text-based diagnostic message. Let’s walk through the search tree as presented in Argus’ Top-Down view.
Highlighted at the top in orange is the search tree root. Argus represents the search tree in a directory view sort of way, so the orange node is equivalent to the tree root node in the illustrative diagram. In the Argus tree you expand the nodes’ children by clicking on the line. Notice how we still use solid and dotted lines to represent the parent child relationship, they now appear to the left of the node.
Above we show one branch in the search tree for the function handler impl block—highlighted in green. In this branch we unify the type variables
F = fn(LoginAttempt) -> bool
T1 = LoginAttempt
Fut = Future<Output = Res>
Res = bool
and add the where-clause constraints as children of the impl block. Notice the constraint Res: IntoResponse
, given that Res = bool
, the constraint requires that booleans implement IntoResponse
, but they don’t. This is one of the root causes of the error and we shal look at how to fix the problem in the following section. But before we jump back to the code and start fixing issues, let’s reflect on the Argus interface and see how we can reach the same conclusion faster.
The screenshots included so far of the trait search tree are from the Top-Down view in Argus. This means we view the search just as Rust performed it: We started at the root question {login}: Handler<_, _>
, descended into the impl blocks, and found the failing where-clause in a tree leaf—highlighted in red. There’s a second failing bound, but we’ll come back to that in the next section. The insight is that errors are leaves in the search tree, so the Top-Down view doesn’t prioritize showing you errors, but rather the full trait solving process.
Up the Search Tree
What if you want to see the errors first? Argus provides a second view of the tree called the Bottom-Up view. The Bottom-Up view starts at the error leaves and expanding node children traverses up the tree towards the root. This view prioritizes showing you errors first.
The Bottom-Up view is the inverted search tree. You start at the leaves and traverse to the root. Here’s the bottom-up version of the Axum error search tree.
--- title: Bottom-Up view of the search tree. --- graph TD root["{login}: Handler"] implRespH["impl Handler<IntoResponseHandler, S> for T\nwhere\n T: IntoResponse"] intoResp["{login}: IntoResponse"] implH["impl Handler<M, S> for F\nwhere\n F: FnOnce(T1) -> Res,\n Res: IntoResponse + Send,\n T1: FromRequest"] isFunc["{login}: FnOnce(LoginAttempt) -> bool"] boolFut["bool: IntoResponse"] loginARqst["LoginAttempt: FromRequest"] implRespH -.-> root intoResp --> implRespH implH -.-> root isFunc --> implH boolFut --> implH loginARqst --> implH class root,implRespH,intoResp,implH,boolFut,loginARqst cssFailure class isFunc cssSuccess classDef default fill:#fafafa, stroke-width:3px, text-align:left classDef cssSuccess stroke:green classDef cssFailure stroke:red linkStyle 0,1,2,4,5 stroke:red,color:red linkStyle 3 stroke:green, stroke-width:4px, color:green
Argus sorts the failing leaves in the Bottom-Up view by which are “most-likely” the root cause of the error. No tool is perfect, and Argus can be wrong! If you click on “Other failures,” which appears below the first shown failure, Argus provides you a full list.
The above demonstrates that Argus identifies Res: IntoResponse
as a root cause of the overall failure in addition to the second failure: LoginAttempt: FromRequestParts<_, _>
. The note icon in the Bottom-Up view indicates that the two failures must be resolved together if you want to us the function as a handler.
It’s always important to read and understand the failing obligation(s) that Argus presents first in the Bottom Up view. These errors are the leaves of the search tree, in other words, the root cause of the overall error; if you satisfy these bounds, then the root bound will also be satisfied.
If additional failing bounds are present under “Other failures,” you need to only resolve a single set of these failures. Argus shows you the set it believes was your intent, but as the developer with intent double check for yourself that it makes sense.
The list of trait implementors is equivalent to what you’d find in Rust documentation. See for yourself in the IntoResponse
documentation. Rust documentation makes a distinction between “implementations on foreign types” and “implementors,” Argus lists both kinds of impl block together.
Moving forward let’s finally fix the last failing bound and get the code to type check.
The above video contains a lot of information. We’ll comment on the two key pieces of information.
-
We look through the implementors of
FromRequestParts
; this being the Argus-identified error, but no impl block seemed to preserve our intent of extracting aLoginAttempt
from request headers. It’s vague to say “nothing seemed right,” and of course fixing a type error may require some familiarity with the crate you’re using or the domain in which you’re working. -
Instead of implementing
FromRequestParts
it turns out we can also implementFromRequest
. We determined this by expanding the Bottom-Up view to reveal that the boundFromRequest
was first a constraint to implementHandler
, and thatFromRequestParts
is an attempt to satisfy theFromRequest
bound. Here’s an annotated image of the Top-Down view to highlight the relationship between these two traits.
Finally, after all of these errors, we have a type correct program. All of this work to get a type-correct program, you can use your free time to implement the actual functionality if you wish.
Wrapping up
Rust uses a mechanism called traits to define units of shared behavior. We implement traits for types with impl blocks.
The trait solver searches through available impl blocks to determine if a given type implements a specified trait. Tracing the steps made by the trait solver is what we call the search tree, the core data structure exposed by the Argus IDE extension.
The Argus interface shows the search tree either Top-Down or Bottom-Up. The Top-Down view is the search tree as generated by the trait solver. The Bottom-Up view inverts the search tree and traverses the tree from leaves to root. The list icon next to a node shows all impl blocks for the trait in that node.
In the next chapter we’ll show off more features of Argus and debug a Diesel trait error.
Trait Methods and Typestate
Every programming language cultivates its own set of patterns. One pattern common in Rust is the builder pattern. Some data structures are complicated to construct, they may require a large number of inputs, or have complex configuration; the builder pattern helps construct complex values.
A great example of working with builders is the Diesel QueryDsl
. The QueryDsl
trait exposes a number of methods to construct a valid SQL query. Each method consumes the caller, and returns a type that itself implements QueryDsl
. As an example here’s the method signature for select
fn select<Selection>(self, selection: Selection) -> Select<Self, Selection>
where
Selection: Expression,
Self: SelectDsl<Selection> { /* ... */ }
The QueryDsl
demonstrates the complexity allowed by the builder pattern, it ensures valid SQL queries by encoding query semantics in Rust traits. One drawback of this pattern is that error diagnostics become difficult to understand as your types get larger and the traits involved more complex. In this chapter we will walk through how to debug and understand a trait error involving the builder pattern, or as some people will call it, typestate. We refer to this pattern as typestate because each method returns a type in a particular state, the methods available to the resulting type depend on its state. Calling methods in the wrong order, or forgetting a method, can result in the wrong state for the next method you’d like to call. Let’s walk through an example.
table! {
users(id) {
id -> Integer,
name -> Text,
}
}
table! {
posts(id) {
id -> Integer,
name -> Text,
user_id -> Integer,
}
}
fn query(conn: &mut PgConnection) {
users::table
.filter(users::id.eq(posts::id))
.select((
users::id,
users::name,
))
.load::<(i32, String)>(conn);
}
Running cargo check
produces the following verbose diagnostic.
error[E0271]: type mismatch resolving `<table as AppearsInFromClause<table>>::Count == Once`
--> src/main.rs:29:32
|
29 | ... .load::<(i32, String)>(con...
| ---- ^^^^ expected `Once`, found `Never`
| |
| required by a bound introduced by this call
|
note: required for `posts::columns::id` to implement `AppearsOnTable<users::table>`
--> src/main.rs:16:9
|
16 | ... id -> ...
| ^^
= note: associated types for the current `impl` cannot be restricted in `where` clauses
= note: 2 redundant requirements hidden
= note: required for `Grouped<Eq<..., ...>>` to implement `AppearsOnTable<users::table>`
= note: required for `WhereClause<Grouped<...>>` to implement `diesel::query_builder::where_clause::ValidWhereClause<FromClause<users::table>>`
= note: required for `SelectStatement<FromClause<...>, ..., ..., ...>` to implement `Query`
= note: required for `SelectStatement<FromClause<...>, ..., ..., ...>` to implement `LoadQuery<'_, _, (i32, std::string::String)>`
note: required by a bound in `diesel::RunQueryDsl::load`
--> diesel-2.1.6/src/query_dsl/mod.rs:1542:15
|
1540 | ...fn load<'query, U>(self, conn: &mut Con...
| ---- required by a bound in this associated function
1541 | ...where
1542 | ... Self: LoadQuery<'query, Conn,
...
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `RunQueryDsl::load`
= note: the full name for the type has been written to 'bad_select-fa50bb6fe8eee519.long-type-16986433487391717729.txt'
As we did in the previous section, we shall demo a short workflow using Argus to gather the same information. Opening the Argus panel works a little differently, as you shall see in the following video. When there isn’t a link to the obligation in the error tooltip, you can always open Argus from the Command Palette or the bottom toolbar.
Below we see that Argus presents more failing bounds than the compiler did. To debug effectively with Argus you should consider all failing founds in the context of your problem and start with the one that is most relevant. You can also compare the failing bound with information provided in the Rust error diagnostic to get you on the right track.
Argus may present more errors than the Rust compiler, it is research software after all. Use your judgement to decide which errors are first worth exploring, if there are multiple, look at all of them before diving down into one specific search tree. We’re working hard to reduce noise produced by Argus as much as possible.
Now let’s dive into the trait error.
Here are some key points from above that we’d like to highlight
-
When opening the Argus debugger the hover tooltip said “Expression contains unsatisfied trait bounds,” but there wasn’t a link to jump to the error. This is an unfortunate circumstance, but one that does occur. In these cases you can open the Argus panel by clicking the Argus status in the bottom information bar, or run the command ‘Argus: Inspect current file’ in the command palette.
-
The printed types in Rust can get painfully verbose, the Rust diagnostic even wrote types to a file because they were too long. Argus shortens and condenses type information to keep the panel as readable as possible. One example of this is that fully-qualified identifiers, like
users::columns::id
prints shortened asid
. On hover, the full path is shown at the bottom of the Argus panel in our mini-buffer. Extra information or notes Argus has for you are printed in the mini-buffer, so keep an eye on that if you feel Argus isn’t giving you enough information. -
Clicking the tree icon next to a node in the Bottom-Up view jumps to that same node in the Top-Down view. This operation is useful if you want to gather contextual information around a node, but don’t want to search the Top-Down tree for it. You can get there in one click.
Turns out we forgot to join the users
and posts
tables! At this point we understand and have identified the error, now it’s time to fix the program. Unfortunately Argus provides no aide to fix typestate errors. We’re in the wrong state, posts::id
doesn’t appear in the table we’re selecting from, we need to get it on the selected-from table. This is a great time to reach for the Diesel documentation for QueryDsl
.
Here we used our domain knowledge of SQL to find the appropriate join methods. We decided to use an inner_join
to join the tables, and then all was fixed.
Finding the appropriate method to change the typestate won’t always be so straightforward. If you lack domain knowledge or are unfamiliar with the terms used in the library, you may have to read more of the documentation and look through examples to find appropriate fixes. When in doubt, try something! And use Argus to continue debugging.