Interactive Debugging with Argus

Argus is a tool for inter­ac­tive explo­ration of Rust’s trait solv­ing process, made pos­si­ble by expos­ing the inter­nal proof trees con­structed by the Rust trait solver. The Argus inter­face cre­ates a lens into trait res­o­lu­tion and does not reduce away impor­tant infor­ma­tion like com­piler diag­nos­tics. This sec­tion intro­duces the Argus inter­face with the two run­ning exam­ples, and shows how Argus can pro­vide more spe­cific error infor­ma­tion for our Axum web server.

A Good Diagnostic, Preserved

Com­piler diag­nos­tics are reduc­tive, explained in . If a reduc­tive diag­nos­tic can sat­isfy our cri­te­ria for a good error mes­sage, an inter­ac­tive inter­face should do no worse. Sim­ple cases should remain sim­ple, at the very least Argus should never per­form worse than a com­piler diag­nos­tic mes­sage.

trait ToString {
    fn to_string(&self) -> String;
}

impl ToString for i32 {
    fn to_string(&self) -> String {
        format!("{}", self)
    }
}

impl ToString for char {
    fn to_string(&self) -> String {
        format!("{}", self)
    }
}

impl<T> ToString for Vec<T>
where
    T: ToString,
{
    fn to_string(&self) -> String {
        format!(
            "[{}]",
            self.iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(", ")
        )
    }
}

fn print_ln<T>(v: T)
where
    T: ToString,
{
    println!("{}", v.to_string());
}

fn main() {
    let v = vec![1.618, 3.14] as Vec<f32>;
    print_ln(v);
}

Shown in is the default Argus inter­face. Failed oblig­a­tions are grouped by expres­sion of ori­gin (where the oblig­a­tion was intro­duced), expres­sions are grouped by func­tions, and func­tions are grouped by file. Advanced Rust users may have other top-level items such as con­stant expres­sions. A key idea in Argus it that there is not a sin­gle cor­rect way to view the proof tree. The best view depends on the pro­gram­ming task. Sim­i­lar to a per­for­mance pro­filer, Argus presents both a bot­tom-up view (start­ing at the failed leaves) and a top-down view (start­ing at the root goal). By default Argus starts in the bot­tom-up view. Recall that we claim: inter­ac­tive visu­al­iza­tion facil­i­tates error local­iza­tion with few inter­ac­tions. By pre­sent­ing the bot­tom-up view first, devel­op­ers are imme­di­ately con­fronted with the poten­tial root causes.

For this sim­ple trait error, the bot­tom-up view shows us the same infor­ma­tion as pro­vided by the com­piler diag­nos­tic: an unsat­is­fied trait bound f32: ToString. That is our root cause. Expand­ing the bot­tom-up view shows the prove­nance of each bound. The top-down view of this exam­ple is unin­ter­est­ing because the trait solver did no branch­ing, and the proof tree is really a “proof stick.” There­fore, it is sim­ply the mir­ror of the bot­tom-up view as shown in .


The top-down view starts with the failed root goal, and inter­ac­tively allows for explo­ration down towards fail­ures. Encoded in the left bor­der is the rela­tion­ship between par­ent and child (And/Or). A dashed bor­der rep­re­sents the Or rela­tion­ship, mean­ing one child needs to hold. This is used when reduc­ing a goal via a trait imple­men­ta­tion rule. The solid bor­der rep­re­sents the And rela­tion­ship, mean­ing all chil­dren need to hold to sat­isfy the par­ent. This con­junc­tive rela­tion­ship is most com­monly intro­duced by goal sub­con­di­tions intro­duced by the where clause of a trait imple­men­ta­tion block.

A poor diagnostic, pinpointed

In we explored how the require­ments for the Axum Handler trait cre­ated a poor diag­nos­tic. traced the trait solver exe­cu­tion; due to branch­ing and back­track­ing in the search the com­piler reports a higher failed bound than the actual root cause. In lieu of reduc­tive diag­nos­tics, we pre­serve the proof tree and are solv­ing an inter­face prob­lem. This means Argus doesn’t shy away from branch­ing—it embraces the full proof tree—devel­op­ers can always dig fur­ther down into failed trait bounds. We now see Argus in action. The root error cause of code is that String doesn’t imple­ment FromRequestParts, bar­ring the func­tion from imple­ment­ing the desired Handler.

use axum::{body::Bytes, routing::post, Router};
use tokio::net::TcpListener;

async fn is_valid_user(name: &str, pwd: &Bytes) -> bool {
    true
}

async fn login(name: String, pwd: Bytes) -> String {
    if is_valid_user(&name, &pwd).await {
        format!("Welcome, {name}")
    } else {
        "Invalid credentials".to_string()
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/login", post(login));
    let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Seen in , Argus has done some­thing that Rust couldn’t—or wouldn’t—it has reported the root cause of the trait error. We did not solve the branch­ing prob­lem or deploy sophis­ti­cated diag­nos­tic strate­gies. Argus uses a small set of sim­ple heuris­tics and fil­ter­ing to sug­gest root causes. These heuris­tics are dis­cussed in . Argus can rank the like­ly­hood of each error and present them to the user because it does not reduce the proof tree, where com­pil­ers need to be con­ser­v­a­tive, Argus can be dar­ing.

The prob­lem of proof tree explo­ration is that of an inter­face prob­lem. The com­piler has large data, Rust types are large, and yet we need to make this infor­ma­tion con­sum­able. Argus isn’t tied down to report­ing tex­tual sta­tic diag­nos­tics, open­ing the room to inter­face design exper­i­men­ta­tion. Some of the ways in which Argus deals with large data is path short­en­ing and trim­ming trait imple­men­ta­tion head­ers.

Path short­en­ing The def­i­n­i­tion path of an item is the absolute, qual­i­fied path uniquely iden­ti­fy­ing its def­i­n­i­tion. Com­mon paths such as std::vec::Vec don’t seem too harm­less. Library paths, espe­cially those involv­ing traits, are a bit harder to read at times. Con­sider a sim­ple social media appli­ca­tion whose imple­men­ta­tion uses the pop­u­lar Diesel library to han­dle rela­tional map­ping and query build­ing.

fn users_with_equiv_post_id(
  conn: &mut PgConnection
) {
  users::table
    // .inner_join(posts::table)
    .filter(users::id.eq(posts::id))
    .select((users::id, users::name))
    .load::<(i32, String)>(conn);
}
use diesel::prelude::*;

table! {
    users(id) {
        id -> Integer,
        name -> Text,
    }
}

table! {
    posts(id) {
        id -> Integer,
        name -> Text,
        user_id -> Integer,
    }
}

The appli­ca­tion has two tables, users and posts, and for some rea­son there’s a query to select all users who have a post with the same id as their user id. (A pro­mo­tional idea per­haps?)The code shown in doesn’t com­pile, there’s a miss­ing INNER JOIN on the tables before fil­ter­ing on equiv­a­lent IDs. Diesel pushes these errors into the trait sys­tem to pro­vide sta­tic safety. The fully qual­i­fied type in the prin­ci­ple error mes­sage is below.

<QuerySource as
  diesel::query_source::AppearsInFromClause<posts::table>>::Count
== diesel::query_source::peano_numbers::Once

To fur­ther beat a dead horse Rust reports “the full type name has been writ­ten to ‘file.long-type.txt’” where all types involved in the error are writ­ten. Tem­po­rary debug files have sev­eral hashes in their names mak­ing the real file­name much longer too. Pon­der this state­ment. The type was too long to be dis­played and the devel­oper needs to dig through out­put files if they want to see it.

Instead of shy­ing away from these large qual­i­fied paths, Argus can shorten them by default, and pro­vide the fully qual­i­fied ver­sion on hover, demon­strated in . Hid­ing qual­i­fied types by default reduces the size of infor­ma­tion attack­ing devel­oper’s visual sys­tem and helps them sift through infor­ma­tion faster. There are, of course, instances where short­en­ing paths by default can be con­fus­ing. If mul­ti­ple paths shorten to the same sym­bol, for exam­ple trait meth­ods within dif­fer­ent imple­men­ta­tion blocks, infor­ma­tion may seem redun­dant or con­flict­ing. In these cases we pro­vide a small visual pre­fix to indi­cate that the sym­bols are part of a larger path.

Trim­ming imple­men­ta­tion head­ers Doc­u­men­ta­tion is a cru­cial resource to help devel­op­ers, espe­cially when using new libraries. Rust doc­u­men­ta­tion is gen­er­ally good, but it could do bet­ter espe­cially when it comes to traits and their imple­men­tors. Con­sider again our run­ning exam­ple with Axum. We have a func­tion login that fails to sat­isfy the cri­te­ria to imple­ment the Handler trait. The reported error is like “trait bound login: Handler is not sat­is­fied.” The intu­ition for many devel­op­ers is to visit the doc­u­men­ta­tion page for han­dlers to see what types do imple­ment the trait. Unfor­tu­nately, the ver­bosity of Rust is a lit­tle jar­ring when sift­ing through dozens of imple­men­ta­tion types in doc­u­men­ta­tion.

Shown in is the imple­men­ta­tion block for func­tions of arity six­teen. There is a nearly equiv­a­lent doc­u­men­ta­tion item for func­tions of arity fif­teen, and four­teen, and thir­teen … and so on down to zero. These blocks are try­ing to say the same thing: han­dlers are asyn­chro­nous func­tions whose first \(n - 1\) argu­ments imple­ment FromRequestParts and whose \(n^{th}\) argu­ment imple­ments FromRequest and whose return type imple­ments IntoResponse.Addi­tional Rust-isms like ‘static and Send fur­ther clut­ter the doc­u­men­ta­tion. Extra ver­bosity in the doc­u­men­ta­tion comes from all those type para­me­ters. As pre­vi­ously stated, han­dlers can have between zero and six­teen argu­ments, there­fore the doc­u­men­ta­tion has one imple­men­ta­tion block for each func­tion arity.

Look­ing through this doc­u­men­ta­tion can be con­fus­ing and it isn’t ini­tially appar­ent which block one should start with. Ide­ally, Rust would have vari­adic gener­ics, a long sought after fea­ture,See online dis­cus­sions at vari­adic-gener­ics-design-sketch/18974. that would allow all these imple­men­ta­tion blocks be writ­ten as one. Vari­adic gener­ics are unlikely to land in the near future so Argus does its best to hide the unnec­es­sary infor­ma­tion by default. Shown in is how Argus hides type para­me­ter lists and where clauses by default, shift­ing focus to the type and trait involved. Of course, this infor­ma­tion isn’t lost and users can click the area to tog­gle its view as seen in pre­vi­ous fig­ures.

Go back to the inter­ac­tive Argus envi­ron­ment and click through the top-down list. Let us know how you’d like for it to be improved fur­ther.

A small sam­ple of user feed­back sug­gests that the Argus view is more read­able than online doc­u­men­ta­tion. This isn’t to say it couldn’t be improved, future work will include hyper­links to library doc­u­men­ta­tion and improved ren­der­ing of Fn trait out­put types. Using the nota­tion -> Ty instead of Fn::Output == Ty to indi­cate return types.