blog.illicitonion.com

A couple of months ago, cargo got a new feature I've been wanting for a while; an option to select the minimum, rather than maximum, compatible versions of each of your (transitive) dependencies, according to semver. So if you say you depend on foobar version 1.1, it will pick 1.1.0 not 1.1.16, as opposed to the standard behaviour of choosing 1.1.16. By traditional wisdom, this isn't something you actually want to use in real life, because newer versions tend to have optimisations / security fixes / ... which older versions don't. But it's a useful tool for checking whether your declared supported dependency versions are accurate.

This has particularly been in my mind recently because of Russ Cox's recent work on semantic versioning dependencies in Go. The tl;dr of that is: Avoid making breaking changes, libraries specify their minimum supported version of their dependencies, and the Go tooling will choose the minimum supported version of each dependency which works for the transitive set of dependencies. It does exactly the thing that traditional wisdom says is bad, but Russ argues pretty convincingly that this is good 1. However, Russ's arguments rely on this being a community-wide adopted standard practice; if the community isn't all using the minimum supported version of things, it doesn't work, because nothing advances the base version of libraries, and everyone ends up stuck with super old versions of everything.

I thought I'd see how minimum versions worked out in the Rust ecosystem. I used my current main Rust project, the scheduling engine of the Pants build tool, as my playground. The project is about 50kloc, and has 134 transitive dependencies. Those dependencies include a handful of fairly common pure-rust libraries (clap, futures, tokio), a handful of C/C++ libraries (lmdb, grpcio, fuse), and a long tail of others.

I was expecting some things not to work, but I was mostly expecting to just need to bump some versions because libraries were using features introduced in newer minor versions than they claimed. I was surprised by some of the ways that things didn't work, and how hard it was to fix them...

Language stability

For the last couple of years, Rust has felt like a relatively stable, but quickly improving language. If you look through the "Compatibility Notes" and "Breaking Changes" section of the release notes since 1.0 (now 3 years ago), pretty much everything is a minor and niche issue; the language has been adding many things, but rarely breaking old ones.

There has been some discussion as to whether the minimum version of rustc you need to compile with should be considered part of a crate's API, and whether increasing that value should require a major version change. I was surprised, however, to find that the opposite was a common problem when pinning to minimum versions - the minimum supported semvers of some of my transitive dependencies are so low that they don't compile with recent (or for some, any post 1.0) versions of rust!

Some examples, with rust 1.25:

Fixing these things is hard

I got my library compiling, eventually, but it took a lot of effort, and required forking several projects. Let's take a look at the log example. Ideally, I should somehow be able to say "I don't care what my dependencies say they need, give them log 0.4.1, that's the log I want to use". I found the [patch] section which looked like it did what I wanted, but it doesn't appear to cover transitive dependencies. Maybe there's something I missed, here?

So I started forking all of my dependencies which themselves had problematic dependencies. Ideally I can get PRs merged to update these things, but it's not obvious that "increase the minimum version you specify so that the minimum version actually compiles on modern Rust" is a reasonable pull request; partially because rust versions don't factor into versioning anywhere, and partially because minimum versions aren't expected to be used, even though they may technically be expected to work. Again, it feels like a weird PR to receive.

Does this matter?

Maybe this doesn't matter, in practical terms. Cargo prefers the most recent available version, and that mostly works for people. And maybe this whole problem is just a relic of pre-1.0 to post-1.0 transition, and it will go away at some point. But it seems strange that we bother to go through all of this writing down semvers of our dependencies, only for them to frequently be lies. Maybe Russ Cox is right, and you need to force people to keep them accurate by actually using the minimum versions. Maybe we should give up with specifying minimum versions at all, and just always use the most recent version (relying on Cargo.lock files for reproducibility). Maybe now that we have -Z minimal-versions, it will be easy to add these checks to people's CI (or even on the crate publishing path), and we can enforce that what we write down is accurate. Maybe this is yet another reason we should be factoring "supported rust versions" into dependency resolution (either by including it in semver, or by tracking it separately in Cargo.toml files). I don't know what we should do as a community, but right now, things feel a little weird.

1

Russ Cox's series of blog posts is very interesting, and well written, and I recommend giving it a read if you're interested in this kind of thing. I have mixed opinions on the design in general, but that's a conversation for a different day!