Pre-RFC: MSRV-aware resolver

Yeah, with the warning I think that’s more or less equivalent in effect to what I am proposing.

With the “ignore rust version by default” solution by default, I imagine we could print something like

Warning: dependency foo requires rust 1.92 while the current version is rust 1.62. Re-run with —rust-version 1.62 to resolve to use an older, compatible version of foo.

But it is reasonable to ask why the software just doesn’t fix the problem if it knows how. So it is reasonable to resolve at current version, print a warning, and require passing the flag to silence the warning.

I do think that printing this warning is a rather major thing, as it, effectivley, selects between the world where we expect the user to have up-to-date rust, versus the world where the compiler is usually outdated.

I dunno about CI, but during development I think I do want cargo to pull old dependencies so that I don't accidentally introduce uses of features that aren't available in the minimum version I specified for each dependency.

You might reasonably respond "well, in that case you should be developing against your minimum Rust version" and I actually agree with that but I don't think it's practical given the way rustup currently works, the lack of official support for old releases, and the fact that I might be working on several projects that don't all have the same MSRV.

1 Like

In such cases you should use the toolchain file to automatically select (and install if necessary) toolchain for your project. Otherwise, you at the risk of unintentionally using newer Rust features in your developed crate and pulling dependencies for an older toolchain will not help prevent it in any way.

2 Likes

Wouldn't that force everyone to use the old toolchain, and not just active developers?

  1. I don't think the toolchain files get published together with crates, i.e. users of such crates will not be forced to use your MSRV toolchain.
  2. You can keep the toolchain file locally without committing it.

It will unless excluded, I've found it very annoying when downloading crates to test stuff that rustup decides to start installing a toolchain, and docs.rs/crater has to have special handling to remove it. But very few people download crate sources instead of checking out repos that that doesn't really matter.

Are you sure? That is how Arch Linux recommends building distro packages for Rust programs. See Rust package guidelines - ArchWiki

Though they do suggest using RUSTUP_TOOLCHAIN=stable, but I'm not sure if that overrides the file based setting.

For Arch specifically an additional challenge is AUR (Arch User Repository) where users can provide additional packages outside the core set officially supported. A bit like PPAs in Ubuntu except they are for single packages and are source only (i.e. you don't publish prebuilt binary packages). This means every user will have to build the AUR package locally themselves. Which again pulls the file from crates.io.

So yes, that use case absolutely matters.

Package builders should not be using rustup, they should be using the distro packaged rust compiler.

This has also not been my experience, every AUR and nixpkgs package I’ve looked at has used GitHub as the source, not crates.io.

You are right, it gets included unless it's part of multi-crate workspace or explicitly excluded. But it gets ignored when such crate is used as a dependency.

As you correctly notes, very few people manually download, unpack, and compile crates from crates.io. When discussing defaults for a new feature we should account for most common and frequent cases.

Regarding developing with an old toolchain to make sure you don't introduce uses of functionality that requires a newer compiler, I don't find this very practical. Especially when clippy is used by a project, you will get much fewer helpful lints with an old toolchain.

I would much prefer if new compiler versions would lint if there's a mismatch between functionality being used and the declared MSRV (at least for standard library items where this information is readily available in the form of attributes).

2 Likes

I'd like to see those lints as well for the simple cases like library functions, but in practice, there are a large number of things that can affect backwards-compatibility with an old compiler version, and many will not be practical to check in a new compiler. That includes not only new library functions and language constructs, but bugfixes as well. In practice, the only way to be sure that something will compile with an old version is to compile it with that version.

In general, though, I wouldn't suggest doing that for local development. Always use the newest stable for local development, and test your MSRV in CI.

6 Likes

Working to catch up and want to establish some basic assumptions I'm working off of

  • Generally, cargo should just work. If we can prevent the user from stubbing their toes on errors, we should.
    • There are situations this has to be balanced out, like with security which is why cargo install was deferred out of this so we can spend more time weighing out how to handle that situation
    • imo intentionally designing things for the user to fail (and for them to then figure out how to get out of that failure) is not a healthy, positive way to drive the behavior we want (avoid stagnation).
  • We should support and encourage developers to user the latest toolchain (with all of its benefits) regardless of the version of Rust they have to support for their clients or that they deploy with
    • imo we shouldn't be telling people "the right way to maintain and verify MSRV is a rust-toolchain.toml file
    • Personally, I also run a lot of tools at HEAD (ie shell scripts that wrap cargo run) and I find rust-toolchain.toml files very disruptive
    • Same could possibly be said for dependencies but imo the value proposition is quite different.

For myself, I think the best out-of-box experience would be for local development to be compatible with the MSRV. Regardless of what you are running, you'll get MSRV compatible dependencies and you won't design for features unavailable to you. Yes, this could also be an argument for developing on the old version. See above. The workflows around trying to maintain a development MSRV and a publish MSRV are too rough to optimize for.

However, I think we could better support any of the above workflows. I got thinking about this because -Zminimal-versions came up in a Zulip thread and I was considering the challenges with stabilization. For me, the big challenge is in determining the workflow, whether its a one-time thing and you can drift towards maximal versions or whether it should be sticky in your lockfile. The way to bypass this is we instead configure this in a way and place that is clearly transient but can be preserved: .cargo/config.toml.

I already updated the proposal to support the following in .cargo/config.toml:

[build]
rust-version = <true|false>

What if instead we exposed this as a way to control precedence within the resolver

[build]
resolver.precedence = "rust-version=package"  # default

with support values being:

  • maximum: behavior today
  • minimum (unstable): -Zminimal-versions
    • As this just just precedence, -Zdirect-minimal-versions doesn't fit into this
  • rust-version= (assumes maximum is the fallback)
    • package: what is defined in the package
    • rustc: the current running version
    • <x>[.<y>[.<z>]] (future possibility): manually override the version used

If a rust-version= value is used, we'd switch to maximum when --ignore-rust-version is set.

Benefits

  • Avoids users stubbing their toe with both MSRV-by-default and MSRV-only-in-CI workflows
    • MSRV-only-in-CI just needs to commit a .cargo/config.toml file with build.resolver.precedence = rust-version=rustc
  • Easy to override in CI with env variables for verifying MSRV or for verifying maximum dependencies
  • Is clear that this is transient (and is as transient as the user wants). allowing us to not be blocked on questions on how we want to handle those situations
  • Doesn't get in the way of supporting hard-error mode in the future, including resolving for MSRV on a per-package basis (improving the workflows around multi-MSRV workspaces, like cargo itself)
  • Don't need to clutter every command with a less-often used flag

Meh

  • Not a big fan of the "equal within an equal"
    • Particularly as it might be read as == when its really <=
    • Also not a fan of even more table nesting (doing build.resolver.precedence.rust-version = "package")
    • We could just do package-rust-version or rust-version-package but those feel weird to me and we are still left needing a way to set the version (if desired)

If people are on board with this config field (independent of what the eventual default is), then I'd propose we shift focus to stabilizing -Zminimal-versions this way. The benefit is we could then update the docs to explicitly set the resolver to maximum in the new Verifying Latest Dependencies cargo book section in prep for the behavior change assuming this Pre-RFC eventually gets stabilized with the proposed value so people reading the version of that page will not have unexpected results.

First impression for just the minimal-versions aspect, I feel like CARGO_BUILD_RESOLVER_PRECEDENCE=minimum cargo generate-lockfile / cargo generate-lockfile --config=build.resolver.precedence='"minimum"' is more confusing than cargo generate-lockfile --minimal-versions or similar. This is a transient step that I take in a single CI job or on rare occasions locally to debug issues, not something that I want configured long-term like the rest of the config file is normally treated as.

In general I am similarly wary of .cargo/config.toml in repos as I am of rust-toolchain.toml. Removing those from packages to avoid unintended influence is another step that docs.rs has to take to try and get a clean build. I have run into multiple occasions where there have been problems with their interaction with my global config, like We need configurably additive rustflags!.

But what does --mimimal-versions mean and what all workflows is it intended for? If you are using it in CI with cargo generate-lockfile, you likely don't care. However, in designing and stabilizing the feature, the cargo team does and that is a major hold up I have with stabilizing it. Smaller, even if rougher, incremental steps can help unblock people and help the cargo team learn more about what users are doing and why.

It's certainly more work to have to remember to do so in the shell scripts, but if they aren't project specific, they should probably be specifying cargo +HEAD run --manifest-path … (or rustup run HEAD -- cargo run --manifest-path …).

Out of curiosity, then, what would you consider a correct usage of rust-toolchain.toml then? There must be some reasonable usage that isn't for pinning unstable versions.

It's definitely useful for normalizing the toolchain version used by a disparate group of contributors to a project without requiring the use of some additional wrapper (e.g. x.py, just, toolkit.nu). It's also quite helpful in the absence of MSRV aware std for catching accidental use of new API surface locally before hitting longer CI test iteration cycles.


A perfect world, every library would have accurate annotation of what release version, so local development can be with the latest versions of everything but validated to only use functionality available on (direct) minimal selection versions.

But that's a hard world to even move towards.


Complete gut instinct, having only skimmed the proposal honestly:

  • Plain cargo update should select the latest rust-version compatible library versions, with a nonfatal warning if that requires adjusting resolution.
    • rust-version is:
      • resolver rust-version, if set; otherwise
      • workspace rust-version, if set; otherwise
      • root manifest package rust-version, if set; otherwise
      • lowest rust-version set in a local package with a possible dependency edge, if any set; otherwise
      • current toolchain version.
  • resolver strategy can be set to transitive-minimum, minimum, rust-version (default), or maximum.
    • minimizing resolutions also prune to rust-version.
    • cli flag available which overrides workspace manifest
  • +msrv synthetic toolchain provided by rustup which uses the root manifest workspace (or package) rust-version
    • likely quite annoying to provide, since cargo is the one to do the manifest inspection; maybe rustup could pass through cargo +msrv for cargo to resolve and trampoline back to rustup?

I don't mean that to say people can't but that it shouldn't be the only (or in my opinion, recommended) solution.

As for other use cases, there is nightly, custom toolchains, etc.

The same as the existing -Zminimal-versions, and the only workflow I really think has a valid reason to want transitive minimal versions is testing compatibility in CI (and rarely locally to debug that CI job's failures). Have there been other workflows proposed that want to use minimal-versions?

I think "pick latest compatible" combined with "lack of rust-version means compatible" could create a surprising behavior:

If a crate did not have rust-version originally, and then published a new version with rust-version in (without changing MSRV, just formalized existing one), then Cargo would pick the last old version without any rust-version, which would still be incompatible. User would get a compilation error. This is a downgrade from the status quo where Cargo would pick the latest version and be able to inform user that the crate is incompatible.

2 Likes

Perhaps the best strategy depends on the distribution of Rust:

  • If user is using rustup with the stable toolchain, the best solution to a higher MSRV requirement is to run rustup update.

  • If user is using a frozen-in-time Rust version, like Debian's, then there's no easy option to update. In that case MSRV-aware resolution is the best choice.

So maybe instead or in addition to the update strategy configured in .cargo/config, there could be a build-time setting that distros could use.

I've been roughly emulating direct minimal version resolution for my projects.

Which is besides the point. When we design something, we are stuck with it. We need to consider what is the right blend of mechanism and policy we give people so we don't trap ourselves in a corner. Just because it happens to work for one specific use case now doesn't mean that is how it should work generally. We also have to consider how different features interact, like when does it make sense to unify the design of two use cases that in the end control the same knob?