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:
- log is, surprisingly, a problematic crate. One of my dependencies specifies a '0.*' dependency on log, I guess because it doesn't want to be the most constraining dependency for something it doesn't care much about - the antithesis of the requirement for the whole community to bump minimums of Russ's proposals for Go. This means that I actually end up with all of log 0.4.1, log 0.3.1, and log 0.1.0, all in my application. But log 0.1.0 uses regex 0.1.0, which was released in 2014, pre rust 1.0, when Rust was a very different language. But it's not obvious what this crate should be doing. Technically, if you found the right compiler, log 0.1 would probably be fine for this library. In all practical terms, it doesn't work with log 0.1, because I suspect there isn't a version of the compiler that supports both regex 0.1.0 and the language features of my dependency. What version should it specify? Should it find whichever version of log's transitive dependencies built with rust 1.0, and specify that? Or rust 1.12? Or rust 1.20?
- rand 0.3.13 (released January 2016) doesn't compile, because it depends on winapi 0.0.1, again released in 2014. I guess it was useful to find out that the protocol buffer compiler I'm using is using a two year old crate for making temporary files, which itself depends on rand 0.3.13 (and that there are much more commonly used crates for this purpose), but am I really going to send them a PR moving them over to use tempfile instead? Maybe... Maybe switching to actively maintained crates with stable versions is beneficial to the ecosystem. But that feels like it would be a surprising PR to receive, and certainly isn't the base expectation of the Rust community today.
- All of my C/C++ dependencies end up depending on pkg-config 0.3.0, which doesn't work with any rust since 1.0. But eeeeeverything does that! libz-sys, libgit2-sys, libssh2-sys, lzma-sys, libsqlite3-sys, libsodium-sys, ffmpeg-sys, libdbus-sys, glib-2-0-sys, and dozens of other widely used libraries maintained by people who know what they're doing, specify their pkg-config dependenciy as ^0.3. Are they all doing something wrong? Surely not...
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!
>Are they all doing something wrong? Surely not...
ReplyDeleteI am pretty sure that they are all wrong :-) Before `-Z minimal-versions`, there was no way to check that the lower bound in Cargo.toml is correct. On the other hand, the "wrong" lower bound is wrong mostly in theory, because Cargo always picks maximum version. So, impossible to check + nothing really breaks combination ensures that, across whole ecosystem, there's a ton of wrong minimal versions.
Yeah; I'm not sure whether making it easier for people to check will make them more likely to fix things, or whether people will mostly not care, though :) Will be interesting to see...
DeleteThanks to MVS you just discover that your manifest file was a lie.
ReplyDeleteYeah, I'm really grateful for the feature. My problem, though, actually isn't that my manifest file is a lie; it's that _everyone's_ manifest files are lies!
DeleteThanks for taking the time to try this and post about it. Really interesting to read.
ReplyDeleteIMO a PR to upgrade the dependencies to the actually supported version isn't weird. Please do file them!
ReplyDelete