Rust version requirement change as semver breaking or not

I'm aware that there have been two schools of thought regarding whether an increase in the minimum Rust version that a crate builds with should be treated as a semver breaking change for the crate.

The root cause of the problem is, of course, that the Rust version itself is not part of the cargo dependency constraints, so cargo won't hold back upgrades that don't suit the Rust version in use.

I'm aware of the arguments in both directions: On one hand, having cargo update (without rustup update) break the build is, for practical purposes, a breaking change. On the other hand, it's bad for the ecosystem to accommodate users of Rust who don't update Rust itself, since semver-breaking changes in the crate version number split the library ecosystem and also waste people's time by causing a chain of semver-breaking version changes throughout the library ecosystem when other libraries depend on a library whose Rust version requirement moved forward.

Now that some time has passed since this was last debated without consensus, has consensus emerged? That is, is there now a clear community norm whether to treat Rust dependency increases as semver-breaking for a crate?

4 Likes

At the current time, I don't think there is a strong consensus. However, I do believe there are some plans for the path forward. IIRC, they sprung out of the post-poned RFC for LTS releases and a different RFC addressing minimum supported Rust specifically. @aturon then outlined some ideas for moving forward here: http://aturon.github.io/2018/07/25/cargo-version-selection/

If I had to guess at the pulse, I think I'd say that our current trajectory is towards a solution that involves Cargo becoming aware of the minimum supported Rust version, but I don't have any good feeling of how strong that trajectory is.

In terms of practice, this is what I currently do:

  • For crates that I maintain that are 1.0 or greater, I do my best to be conservative with the minimum supported Rust version. However, I specify a policy that permits bumping the minimum supported Rust version in minor version releases. So a 1.1 release could require a newer Rust than a 1.0 release. This is somewhat of a compromise, and I'm not sure it's a very good one either. Example: https://github.com/rust-lang/regex#minimum-rust-version-policy
  • For crates at 0.x, I also try to do a minor version release when bumping the minimum Rust version. In this case, it's a semver bump as opposed to the aforementioned strategy for 1.0 crates which is not a semver bump.
  • For applications, I've switched to "requires the latest stable release of Rust, but all patch releases will compile on the same version of Rust that its corresponding minor version compiled on." I tried to be more conservative about this for a while and even succeeded, but once I switched to crossbeam-channel, ripgrep grew a whole new mess of dependencies that made it completely intractable for me to stay on top of this. So I gave up after consulting interested parties: https://github.com/BurntSushi/ripgrep/issues/1019

I do not think the current situation is sustainable. Even though I specifically watch for stuff like this in most of my dependencies, I still miss things. The only way it's going to get fixed IMO is either with a very strong community rallying point (LTS releases or @aturon's "shared policy") or with tooling (@aturon's "stated toolchain"). In particular, many of the more widely used crates that I maintain are quite conservative and aren't seeing major internal changes, so there's really no strong impetus to use new language features. On occasion, new features come along that I want to use, I've ended up achieving that via version sniffing and conditional compilation. It's not ideal and certainly cannot be achieved in every case. For crates that are moving more quickly or want to take advantage of new language features, a conservative MSRV policy is probably quite annoying and/or difficult to adhere to.

10 Likes

I think implementing RFC 2495 fully (with version taken into account during deps resolution) is the best answer (edit: I've prototyped it here!) This way you can have your cake and eat it: nobody's build will break if you start requiring a newer version of Rust at any time.

Semver-major bump is disruptive, and causes a transition period when the library gets duplicated in user's dependency tree and/or its interfaces aren't compatible across libraries. It's not a pain I want to subject users regularly to, so I support only latest(ish) stable and break old Rust versions without remorse.

If Rust had LTS I could think twice before breaking LTS compatibility of "finished" crates, but I probably just wouldn't support LTS versions for new crates in development.

2 Likes

Thanks. Based on this, I published encoding_rs 0.8.11 as semver non-breaking. I didn't get semver complaints. (It did turn out that I had accidentally broken clippy-compat on stable by following the guidance given by nightly clippy, so 0.8.12 reverted that bit.)

I hope there won't be an LTS. Not having an LTS but having compat with old code for real has lead to Rust not getting stuck for the lifetime of an RHEL release. It would be such a strategic blunder to introduce an opportunity for stagnation now that Rust has gotten this far without it.

7 Likes

FWIW, I did notice. :slight_smile: I just don't have the energy to really pursue it any more. I think as long as the MSRV is in a CI config somewhere, then that's good enough for now until we get better tooling (or ecosystem) support.

2 Likes

So, I just got hit with a rust version requirement changing within a dependent crate which is then causing build failures for previously working builds (see https://github.com/rust-onig/rust-onig/issues/97). The crate (onig) made only a minor version bump which, by semver rules, is supposed to be an API neutral and non-breaking change. But it now newly breaks previous builds for some earlier versions of rust.

So, ultimately, I have to pin the specific version. And, logically, to preserve the function of my code, I should really pin the versions of all dependent crates. That's annoying and surprising. And I doubt the ossified code that results is what the community wants to happen long term.

Unfortunately, I'm not embedded enough in the community or well-versed enough with rust to make any well-reasoned suggestions, especially given the long back-and-forth that seems to have been going on about this topic (and the related RFC). But I think some resolution of this needs to be a priority.

2 Likes

I think the general tactic for approaching the problem with dependency management is that, if your crate is a library, expect to let Cargo do dependency deduplication and stuff, (e.g. the default behavior.) But if your crate is an binary executable, you probably want to pin versions for reproducibility.

It isn't a perfect strategy, obviously, but you were using the best practices up until onig made a breaking change without following semver. There is some really cool research to help address this problem, but it still requires crate authors to use it in CI. And it may not fit the bill anyway, since it won't take the rustc or Cargo versions into account...

1 Like

A critical thing you could lend to the conversation here is why you require your code to continue working on older versions of compilers. When I first started thinking about this, I had thought such things would be important for packaging in Linux distros, for example, but that has turned out to not matter as much as I thought it would. Collecting use cases for why this matters is a key part of moving this conversation forward.

Thus far, I don't think I know of any core crate who is rigidly putting "bumping MSRV requires a major semver version increment" into practice. From what I can see, the core crates in the ecosystem are being conservative in an ad hoc manner, while simultaneously supporting newer features via compile time version sniffing. For example, byteorder will enable its i128 related APIs if the rustc version is new enough. This keeps compatibility with older Rust releases while still doing the sensible thing for newer releases. Other crates, like serde, do the same.

For applications, you don't really need to "pin" dependencies. You can instead rely on Cargo's lock file for reproducible builds. The build failure you cite is, I'm guessing, a library that doesn't keep a lock file committed. If reproducible library builds are important to you, you might consider committing a lock file for your library.

Otherwise, I don't think much has changed since my previous comment in this thread.

3 Likes

I'm curious: What kind of constraint blocks you from upgrading to something newer than Rust 1.27?

One concrete reason for caring about Rust version is that I like to cargo clippy -- -D warnings on CI. In order to keep this from breaking when new lints are introduced, I pin the Rust toolchain version that this CI job is running.

Of course, I bump that version somewhat frequently, so I've not run into issues. It's also trivial to bump since my repos have that hard clean-compile rule and it's an internal only detail.

In any case, I think the solution to MSRV is --minimal-versions and bumping MSRV in minor updates (and only guaranteeing MSRV with --mimimal-versions; you may not get the most performant code from dependencies but it should work (otherwise the Cargo.toml is lying)).

My error here ... the Cargo.lock file was inadvertently updated during a large rustfmt reformat commit. That's ultimately what caused the CI build/compile failure for v1.27.0. Replacing the prior Cargo.lock file, as you intimated, fixed the issue.

But, this is still a concerning issue for me since it makes upgrading dependencies much more of a problematic endeavor. And a problem crate might be indirect, a dependency of a dependency, which can be difficult to find and fix. I'd prefer a more formalized system which helps make updating crates less "dangerous".

The version pinning to v1.27.0 is somewhat arbitrary but I wanted to give users some notice of what minimum version of rust is able to successfully compile the utilities. I don't think that requiring everyone to update to the most recent rust version to compile a crate is a requirement that I want to push onto users. It seems unnecessarily onerous and likely surprising for users. I do think some reasonable (and stated) backwards compatibility is a good idea.

I do thank you for your continued attention and input into the issue.

There is no hard constraint. The utilities have for some time specified a minimum supported rust version for compilation. The last crate dependency update (of onig, to fix a windows compilation bug) required increasing the minimum version of rust to v1.27 in order to compile. That's what set the minimum version.

But, it's just that, a statement to users of what version they need to build/compile/install the utilities. I think that keeping a minimum version helps allow wider use of the utilities without placing a burden on users to update to the most recent rust just to compile/install them.

For application and (especially) library authors, I assume the desire to support older compiler versions is an assumption that perhaps someone, somewhere, has a need to use an old compiler, and they don't want to limit their target audience. A reasonable desire, but perhaps somewhat abstracted from the direct drivers.

Perhaps a different way of phrasing the same question, concentrating more on the audience of users of old compilers: what are the concerns that might prevent you upgrading your compiler version?

Then: for each of those concerns, what can be done (by you, by the rust project, by specific crate authors if relevant) to address them?

Some (mostly hypothetical) concern - response examples:

  • New compilers might introduce new bugs, or we might be somehow dependent on a bug/quirk of the current compiler that a new one fixes!

    We need a more extensive and comprehensive test suite to ensure that everything we care about still works after an upgrade

    New dependency versions might also change something, so we never upgrade our dependencies either

  • Distro lag constraints: "Unfortunately, as you already know, redhat.."

    Rust comes from rustup, and ~all of our dependencies come from cargo, not rpm, so it's really not as much of a problem as we were trained to expect from other software ecosystems

  • We release binaries to support customers and need to build patches for old release branches using the same toolchain we used at release, to minimise any potential unintended changes

    We don't update dependencies either (as above), and are irrelevant to this topic

    We will take patches to dependencies selectively if they fix problems, and use semver to help us decide which are appropriate; we really care to know that the MSRV changed!

  • New compiler versions might make us do a lot of work updating our old code and that's scary and hard. We've been burned by other compilers before.

    Rust does an amazing job at minimising this, and perhaps could better emphasise their success here (case studies, testimonies, etc).

  • We have some complex build system that integrates a lot of native tools and libs with rust, and the constraints come from there.

    It's more important that crate authors support linking against older platform libraries than it is that these (FFI and -sys) crates support being compiled with older rustc

  • We're using a platform that isn't supported by rustup, with rustc from a native package system that lags behind: we're stuck.

Some other observations, from the ecosystem in general:

  • data about what compiler versions are in use would be good. I assume crates.io can track cargo user-agent version banners and adoption and how long the tail is, and that this is probably already being done at least in aggregate, if not per-crate. Better visibility of this might help inform people making MSRV decisions for their crates, and decisions by orgs about when they've been left behind and it's time to upgrade their toolchain.
5 Likes

Except it shouldn't really be a burden to update Rust (unless you insist on getting the compiler from Debian stable's main repo, but then you get 1.24 and not 1.27).

As noted upthread, Red Hat has Rust on a 3-month cycle, so Red Hat isn't a reason to support particularly old Rust. (Red Hat is at 1.29 and not 1.27.)

I disagree that basically requiring a user to update rust in order to compile a crate is a small burden.

And that's before including the issue that rust is an evolving target, deprecating functions/operations in the core language/libraries on an ongoing basis. So, a crate might not even be able to compile with a more recent rust version.

IMO, there needs to be some, formally defined, points of stability.

1 Like

Deprecating doesn’t mean removal, so that shouldn’t affect things.

There is a formally defined point of stability, and that came with and is the point of 1.0. See RFC 1105 API Evolution. The TL;DR is that any "minor breaking change" is only a breaking change because of type inference.

We could develop a tool for "fully elaborated form" where everything is UFCS and nothing is broken by minor breaking changes. But unless you get really unlucky, you will never be caught in a minor breaking change. (They're all run through crater/cargobomb to catch the likelihood of breakage.)

Yeah, I agree, though I perhaps didn't make the intent of that 'quoted' phrase clear - I wasn't picking on redhat in particular, just using it as a familiar example of a platform where stable versions are prioritised heavily, often to the frustration of software devs. The point was that the rust ecosystem in general is less subject to the vagaries of distro packaging differences (including lag) than users have come to expect in other cases.

It still can matter for some native dependencies (I keep tripping over openssl shared library versions trying to run an executable built elsewhere), but in general I think people assume or expect this to be more trouble than it turns out to be. They might be holding on to an initial assumption of needing to not upgrade the compiler, and it's time to evaluate whether that's actually true in practice.

This thread is asking for that evaluation, so we can identify what the real use cases and drivers for old compiler support are.

... except when it does, because so many crates like to deny warnings, and deprecating creates new warnings.

3 Likes

This is the crate explicitly opting into breakage, though. If you're gating cargo clippy -- -D warnings on CI, it should be a pinned toolchain version, as there are no guarantees on warnings' stability. That's how C(++) gcc got -Wall -Wextra -Wpedantic -Wsomething-else, since they didn't want to break things using -Wall or whatever lint group with deny-warnings.

Rust also has another weapon against lint escalation. --cap-lints warning will make any lint only emit a warning instead of an error. This is automatically applied to dependencies in the dependency graph, and can be used in your deploy scripts if you're so scared of new lints.

I don't think the exists any situation where rustc (N+1) can't compile a crate rustc N compiled that wouldn't be considered a bug in the compiler.

2 Likes