2023-02-27

If a Rust Project Compiles

Over the last week, I managed to finish Protohackers part 6 in Rust.

For almost the entire project timeline, I did what in all other low-level languages would be an incredibly bad idea. Instead of building a small MVP, getting it up and running and then continually adding features, I went all the way from an empty main() to a finished application that almost worked flawlessly on the first run.

It’s crazy to me that if your Rust project compiles, you are likely more than 90% done.

Below I will go over the few errors that did present themselves and my take on why these were not preventable by the language design itself, as well as my thoughts on why it worked out so well. For a TLDR, see Conclusion.

Specification Errors

The following are all errors I encountered that was because of me not following the specifications, I forgot to:

  • Interpret a heartbeat-request interval of 0 as “I don’t want a heartbeat” (edge case).
  • Multiply speed by 100 in outgoing message (weird specification).
  • Round speed to nearest integer.
  • Let new dispatchers take over the job of a disconnected dispatcher (by far the largest miss).

Rust can’t (so far) tell you when you’ve simply interpreted something differently or just missed a point on what is described in the specifications. However, Rusts strict rules at multiple times made me stop and think:

oh yea, what to do in this case? I should probably check the specification.

I think this reduced the number of specification errors considerably.

Besides that I found it an incredibly easy process to translate the specifications of the problem to actual rust code because of Rusts type system. Once the basic types are in place, all code that uses these types can be written in a way that makes the code impossible to compile without conforming to these specifications. These are all inbound and outbound messages from the server, neatly wrapped up in two enums:

pub enum ClientMessage {
    PlateDetected {
        plate: Plate,
        time: TimeStamp,
    },
    HeartbeatRequest(Duration),
    IAmCamera {
        road: RoadID,
        position: Mile,
        speed_limit: Speed,
    },
    IAmDispatcher {
        roads: Vec<RoadID>,
    },
}

pub enum ServerMessage {
    Error(String),
    Ticket {
        plate: Plate,
        road: RoadID,
        p1: (Mile, TimeStamp),
        p2: (Mile, TimeStamp),
        speed: Speed,
    },
    HeartBeat,
}

All match statements on these types need to be exhaustive, by default giving a very high degree of edge case coverage.

Runtime Errors

I encountered one runtime error, in the part of the code base that was the most hacky (5 block indents are never a good idea). This error did clearly print out an error message in the console, but it was so flooded by others that I didn’t spot it for a while (in a async runtime all threads are not terminated if one panics).

If you have to see my crappy code for this part, here it is. Can you spot the bug?

fn should_be_ticketed(
        entries: &mut Vec<(Mile, TimeStamp)>,
        speed_limit: Speed,
        ticket_days: &mut HashSet<u32>,
    ) -> Option<((Mile, TimeStamp), (Mile, TimeStamp), Speed)> {
        let n = entries.len();
        for i in 0..n {
            let (m1, t1) = entries[i];
            for j in (i + 1)..n {
                let (m2, t2) = entries[j];
                let speed = 3600.0 * ((m1 as f64 - m2 as f64) / (t1 as f64 - t2 as f64)).abs();
                if speed > speed_limit as f64 {
                    entries.swap_remove(j);
                    entries.swap_remove(i);
                    let d1 = t1 / 86400;
                    let d2 = t2 / 86400;
                    let has_been_ticketed_for_day =
                        ticket_days.contains(&d1) || ticket_days.contains(&d2);
                    if !has_been_ticketed_for_day {
                        ticket_days.insert(d1);
                        ticket_days.insert(d2);
                        return Some(((m1, t1), (m2, t2), (100.0 * speed).round() as Speed));
                    }
                }
            }
        }
        None
    }

I accidentally removed items from within the loops even in the case I didn’t return! That was it. no other runtime errors. None.

I would classify this as a logical error. The program did panic at runtime. But even if it wouldn’t have, I would have had to find why it was not behaving correctly and remedy it.

Honorable Mentions

Let’s look at one of the more error prone cases that actually didn’t go wrong, but felt like they could have: interpreting messages from the incoming TCP byte stream. One type of message that could be sent form the client was an IAmCamera message, telling the server that the newly connected client will be sending position/registration plate information about passing cars. Once all types of possible client messages are encoded as enum variants, decoding this variant from it’s binary representation looked like this:

match <message identifier byte> {
    ...
    0x80 => {
        let expected_len = 1 
         + size_of::<RoadID>()
         + size_of::<Mile>()
         + size_of::<Speed>();

        if src.len() < expected_len {
            src.reserve(expected_len);
            return Ok(None)
        };
        //Everything is good, we can safely consume the message:
        src.advance(1);
        ClientMessage::IAmCamera {
            road: src.get_u16(),
            position: src.get_u16(),
            speed_limit: src.get_u16(),
        }
    },
    ...
}

There are some possible pitfalls: Accidentally omitting src.advance(1) to skip the message identifier byte, forgetting to check message length or consuming parts of the message before we are sure we can consume it in its entirety. However one of the most important things is enforced: If the message specification enum ever is changed, this would not compile and yell at us. One thing i felt I was missing here is a way to check the length of an enum variant. (ie. would have been nice to be able to do: size_of::<ClientMessage::IAmCamera>() or similar). The general problem that made this harder to describe in a less error prone way was that some messages length depended on it’s contents. One possible way of doing better would be to implement a binary serde deserializer for the enum and contained length delimited strings, oh well another time!

Conclusion

After compiling the program for the first time after over 3 days of working on the project, I had a handful of specification gaps and one runtime bug. The level of abstraction and robustness that Rust provides without compromising on low-level control is simply remarkable. If your asynchronous rust project compiles. You are with a high likelihood over 90% done. Even if you didn’t run it at any prior time.