Compiler and language stability guarantees instead of LTS

The thread about possibly doing LTS releases of the toolchain seems to have reached a consensus that there is no particular appetite for doing this internally, and also it’s unclear whether an LTS would even be solving a problem that people have. At the same time, that discussion did turn up a bunch of problems that maybe should be solved, and it seems to me that solving them might also address the scenarios that led @epage to start the earlier thread.

Here’s my summary of the “bunch of problems:”

  1. The language does not make it easy to support both old and new versions of either itself, or dependency crates, at the same time. (That is, it can be difficult or even impossible to write “polyfills” for added functionality in new versions of one’s dependencies, particularly across breaking changes.)

  2. People working on the core language are frustrated about not getting enough feedback on features in development, and are reluctant to do anything that will make the process of pushing new features out to stable any slower than it already is. To some extent this frustration is shared by all library crate authors as well.

  3. It can be very difficult for downstream repackagers to stay up to date with the toolchain. As a consequence, crate authors face demand (in the economic sense) from repackagers and end users to keep their code working with old versions of the toolchain, even if they have no other reason to do this. Continuing to support old toolchains may mean continuing to support old versions of dependency crates as well.

  4. It can also be very difficult for anyone doing software development in Rust to keep up with the pace at which the language itself is revised, and the pace at which their own dependencies are updated. This is particularly bad in contexts like “mission-critical and functional safety systems” (quoting Ferrocene’s mission statement) where any change at all may involve repeating expensive, time-consuming certification procedures.

In the earlier thread I said

MSRV would immediately become a lot less important if new language and library features came out at the same cadence as editions, rather than continuously.

I still think that the basic idea there has merit: if we could cut the frequency of “have to spend some time catching up” events from once every six weeks to once every three years, that would make problem #3 much less troublesome and would also partially address problem #4. However, it would obviously make problem #2 even worse. Since I wrote that, it’s occurred to me that we can have it both ways. Like this:

Make toolchain updates trivial, using stability guarantees

Suppose we start guaranteeing that:

  • All toolchain releases that have edition N (e.g. 2027) as their highest supported edition, will be buildable using the first toolchain release that had edition N-1 (e.g. 2024) as its highest supported edition, and using a snapshot of crates.io and all non-Rust dependencies (LLVM most importantly) as they were on the date of that first toolchain release.

    This will mean repackagers only have to worry about updating the dependencies of the toolchain itself when the edition changes. Repackagers who maintain a chain of bootstrap compilers going all the way back to what mrustc can handle will no longer need to include every single release in that chain, only the first release from each edition. That should pretty much eliminate problem #3.

    The principal cost of this guarantee is that the compiler proper will need to maintain a MSRV that may be as much as six years old. However, this applies only to the compiler proper, not to std or clippy or any other toolchain component that gets built using the new compiler. I would like to think it is not too much to ask of the compiler team, and I suspect it could be made substantially easier by addressing problem #1 (see below).

  • The set of language and runtime library features that are enabled by default will only ever change when you choose to change the edition your own code is compiled with.

    This means developers will no longer have to worry that doing development against the current toolchain will cause them to break builds using their MSRV by accident, which is the big reason I see for why one might hesitate to update one’s own personal copy of the compiler.

    We already make strong guarantees that code that does compile using toolchain release 1.x will still compile using release 1.y for any y > x, unless you change the edition. Think of this as extending that guarantee in the opposite direction as well: anything that compiled using 1.y will compile using 1.x, back to the point where x is so old that it doesn’t support the selected edition, unless someone opted into a language feature early (see below).

    The cost of this guarantee is that we substantially slow down the rate at which new language features reach “production,” exacerbating problem #2. I think we can fix that by making it easy to opt into pieces of the next edition, as it were. We already have that mechanism for experimental features, we “just” need to extend it to stuff that’s stable but not yet enabled by default.

Extend #[feature] to stable features not part of the current edition

This is the way we avoid exacerbating problem #2 while addressing #3 and #4. If you want to use something that’s stable, but not part of the current edition, you just ask for it with #[feature], in the same way you would have while it was experimental.

This is a tiny bit of friction, but it pays for itself in that you no longer have to guess what your MSRV is. It’s the first version that supports your selected edition plus all of the #[feature] tags you enabled, or the maximum MSRV of all your dependency crates, whichever is newer, and Cargo can (should be able to) calculate it for you.

This also takes a step in the direction of allowing people to use not-yet-stabilized features with the stable toolchain, which, on net, I think would be a good idea (for instance, it would reduce problem #2 even more).

The cost of doing this—which we are already paying today, by default, I’d like to point out—is that you may be forced to take an MSRV bump in order to update a dependency crate. But this is just a special case of problem #1! You only need to update a dependency crate when you need either a bug fix or a new feature from that crate, but the ecosystem pushes people toward updating eagerly because it’s difficult to support old and new versions of dependencies at the same time. So the solution is to address problem #1 and then encourage people to keep their dependency ranges as wide as possible.

Invest in language features that facilitate polyfilling and wide dependency ranges

I know that polyfilling is not easy today, but I don’t know what would help, except in the simplest cases. One thing that I could have personally used not so long ago was the ability to inject a polyfill definition into someone else’s impl:

#[polyfill(feature(int_roundings1))]
impl usize {
    pub const fn checked_next_multiple_of(self, rhs: Self) -> Option<Self> {
        match try_opt!(self.checked_rem(rhs)) {
            0 => Some(self),
            // rhs - r cannot overflow because r is smaller than rhs
            r => self.checked_add(rhs - r)
        }
    }
}

Because this isn’t currently possible, I had to make the polyfill a free function, and that means I’ll have to change the caller if and when that particular crate’s MSRV rises to 1.73 or above, instead of just deleting the compatibility definition. It also means I don’t get std’s definition of checked_next_multiple_of even if it’s available.

I don't think we need to solve polyfilling in order to make any of the other changes I suggested, but I do think it needs solving. Probably the right thing at this stage is to start a small working group on the topic to hash out what features would ideally be added.

5 Likes

I think this can be simplified to get the same outcome, in a more easily-digestible fashion, without introducing any new concepts: add only the rule that language and standard library features are available only if the declared MSRV (rust-version) is new enough to include them. (With an exception for features introduced before this policy, to avoid breaking existing packages.)

This way the developer process is simple: look at the stabilization version number in the docs for the feature you want to use, then either update your rust-version to that, or decide not to use it. If your version is already high enough, no code change is needed. You don't have to make a big list of #![feature] attributes, or think about whether adding one is a MSRV bump.

I think this works out to be completely automated MSRV enforcement, but I'm not sure. If so, I think I like this idea on its own independent of any questions of what the compiler development and release process should be -- it mostly solves the already-existing pain point of MSRV checking. (Of course, there can still be compiler bugs that mean a newer version is required.)

14 Likes

The principal cost of this guarantee is that the compiler proper will need to maintain a MSRV that may be as much as six years old.

I am not sure how that is realistically viable in a world where many of these features were not even conceptually possible six years ago:

3 Likes

We've got a start on this with a clippy lint. The problem is in characterizing enough of the standard library, language, and compiler to make this definitive is likely beyond the scope of work anyone will be doing for the forseeable future.

3 Likes

Without commenting at all about the desirability of the plan, I want to respond to a couple of points on the feasibility:

This also continues to make problem #2 worse: sometimes, some of the most thorough testing we get of new language and library features is the compiler itself rapidly adopting them.

This will not make software build with 3-6 year old compilers. We have feature flags for "here's a feature we intentionally added to the compiler". We don't have feature flags for "we fixed a bug in the compiler", and we don't keep the old buggy behavior around. The few times I've ever tried to get code running on an older compiler, the point that I stopped was "this is depending on the absence of a bug".

Same problem as above: this doesn't help in the cases where we fix a bug in the compiler and code starts depending on the absence of that bug.

13 Likes

This is not responsive to your post in whole, however I did want to flag that there are two unstable features that do aim to make it easier to write code that's compatible with multiple rust versions while still being able to take advantage of newer features. Those are:

10 Likes

I agree that it wouldn't make sense for rustc to continue using unstable features but only the set that existed three years ago.

What would be realistic would be for rustc to stop adopting new unstable features, and slowly migrate away from existing ones. In a few years it would probably manage to eliminate them all. At that point, setting a MSRV three years in the past wouldn't be much additional burden.

Should rustc do that? Perhaps not; @josh pointed out the testing benefit of having rustc use unstable features. I'm not a compiler developer so I won't opine. But it's certainly possible.

(Personally I have an interest in making libstd compilable using stable, but it doesn't affect me whether rustc can be.)

1 Like

I think this misses a major reason why Rust has a very fast release cadence in the first place. Releasing every 6 weeks significantly reduces stress and urgency for shipping features. If a just stabilized feature has a bug, no problem, just revert, take some time fixing it and ship it in 6 or 12 weeks instead.

But if missing the date means having to wait 3 years until it's properly shipped, that means it will be extremely stressful for everyone involved to get it shipped before the next deadline. This is a problem that editions already suffer from, extending it to all features will lead to way more stress and burnout. This is bad.

34 Likes

This sounds simple and easy, but it's a mistake to tie language and/or runtime features so tightly to the version number of one particular implementation of the language. In the future, it's likely that we will see a variety of Rust compilers -- there already are at least three that I can think of -- with somewhat divergent audiences and therefore somewhat divergent feature sets. That's not a bad thing! But it does mean that treating language capabilities, beyond the baseline provided by the edition number, as a list of toggle switches rather than a monotonically increasing version number will work better.

(Edition numbers are, at least in principle, independent of implementation, but ideally, in the long run, I'd like to see them deemphasized and the role of #[feature] expanded to cover what they do as well.)

Well, that's not terribly surprising since the compiler gets to use all the unstable features it wants without having to be "on nightly" (except insofar as in some sense the compiler is always "on nightly"). Maybe what this means is we need to stop restricting unstable features to nightly first. Relatedly:

Yes, I was assuming that this would be part of the process, and the "bootstrap compiler can be six years old" part of the proposal would only come fully into effect with edition 2027, or maybe even 2030, although I'd like to think it wouldn't take that long.

Also, perhaps this is a reason to get really ambitious with the polyfill support. Like, imagine a world where you could polyfill for the absence of feature(if_let_guard) or feature(negative_impls). I wouldn't want to have to implement that with proc macros but it is possible in principle...

This surprises me. I don't tend to do complicated things with generic types or proc macros or anything, but does it really happen often enough to be a problem, that compiler bugs affect whether code is valid? Can you provide examples?

Under my proposal, features still ship on a 6-week cycle. Does it really matter so much whether one has to turn them on with #[feature] for a couple of years? I don't see that as any different than use std::... for something that's not in the prelude.

If you suggest that everyone just use feature() whenever they want a feature every 6 weeks (so like today), then I fail to see how it's any different from today. If you expect many libraries to stay away from using features before the edition, then you're back to the rush problem.

10 Likes

Then let's say that it's the version number of Rust The Language instead, which happens to currently coincide with the version numbering of the primary implementation, but can start to diverge whenever that seems best.

One of the very good things about Rust as it stands is that there aren't any language forks. Code that does not use unstable features can be relied on to work. I can imagine using #[feature] for implementation-specific experimental features, though — that's more or less how nightly works today. I just don't think we should make it normal practice to list a heap of #[feature]s in programs using stable Rust.

13 Likes

A bunch of stuff worth discussing!

This is an issue, agreed. Especially the viability of writing compatibility shims (e.g. impl rand_core::0.5::RngCore for rand_core::0.6::RngCore and vice-versa). But it seems mostly disjoint from the other issues, thus deserving its own topic.

A significant problem here is documentation, and accessibility thereof:

  • It's not always clear how to use an experimental feature due to insufficient documentation in the RFC or mis-matches between the RFC and implementation (as problems with the original specification are discovered but RFCs tend not to get updated).
  • It's not always clear what the intended limitations of a new feature are and what is just a bug in the current implementation (without reading through many comments on the tracking issues).
  • There are many stagnant unstable features; it's often not clear (without extensive reading through comments) which are likely to make it into stable soon, which still need a lot of work, and which are still years away (if ever).

IMO the main issue here is mis-match between crates over which rustc versions they want to support (making it hard to update dependency A because it depends on B which requires a newer rustc than your crate wants to support). Tying new features to new editions kind of helps — but only if crates don't opt in to new features, which it sounds like would happen with your proposal.

Ferrocene requires some form of LTS, yes. This also implies that crate authors may have to make a concious decision whether to support Ferrocene (or other LTS versions).

But otherwise, is this an issue? Yes, some features like async and GATs have significant learning curve, but this is more about making the language as a whole more complex than replacing an old way of doing things. (There are also examples of the latter, e.g. the path & module changes in 2018, but these are rare and not without good reason.)

2 Likes

I don't believe this is helpful at all. Rust has excellent stability in the form of backwards compatibility. Unless you are Ferrocene and doing it for paperwork reasons there is absolutely no need to use anything except the most recent stable.

If you have non-public code (that thus won't be covered by crater), the best thing you can do is set up a CI to build nightly and/or beta. Maybe not on every change, but at least on a daily or weekly basis. That way you can report any issues well in advanced of them hitting stable. This will benefit not just you, but everyone.

Now with that out of the way, let's consider the other motivations:

Hard to support old and new versions: Yes it isn't easy currently. MSRV resolver will help with this. But it isn't a real problem for most people:

I think having a MSRV of -1 or -2 is entirely reasonable and you shouldn't need to support old versions (since all your users should be using the latest and greatest anyway). Open source authors should not be beholden to those who drag their feet on upgrading, so you won't be able to use those crates in their most up to date version with an old Rust anyway.

You don't get to both eat your cake and keep it basically. Either you stay on all old or you upgrade everything. The rust compiler isn't special here.

This ties back to the Ferrocene discussion. Presumably you would need all your dependencies to be verified, not just the compiler. So you shouldn't be on the latest version of libraries from crates.io either, you would update those at the same slow pace as the compiler. Thus this becomes a non-issue.

There is a basic tension between people moving fast and those who like to drag their feet. That's it, and I'm arguing that dragging your feet and then complaining about things being hard you only have yourself to blame.

Downstream packagers: Sure, but only because they make things artificially hard for themselves. Look at how Arch Linux packages Rust packages instead, working with crates.io instead of against it. This makes packaging rust packages for Arch fairly painless.

You package binaries (or libs with C APIs i guess as well, though I havent done that myself) instead of libraries and rely on lock files and crates.io. You trust cryptographical hashes to do their job. Instead of the insanity that I understand Debian etc are trying to do (I have not looked into what Debian does myself, so this is based on what I have heard. I understand they package the source for all dependencies? Because they don't trust crates.io or they don't believe in cryptographically secure checksums? Seems insane to me.)

Is it hard to keep up for developers? This is of course subjective. But I'm arguing no. It is a bit complicated once, when you are new to the language and there is a bunch of new things not covered by the rust book. The solution here is to update the rust book more often to reflect the latest state of the ecosystem.

Other than that, read the changelog every 6 weeks. Recently there haven't been that much new stuff per release. So also a non-issue.

Again the Ferrocene case is different, but very niche. They need to figure out how to work with the community rather than against it. So far I haven't seen any big problems (at least in public) so status quo seems to be working just fine for all parties involved. It would be useful to have a list of actual problems this has caused for them rather than arguing in abstract vague terms.

9 Likes

I have the impression that a lot of the demand is from large or slow-moving organizations that have a lot of bespoke processes and their own build systems. Their projects are complex enough that they rely on implementation details (knowingly or not) rather than stable and test-covered interfaces and so each update has some risk of breakage for them. Debian is just an open-source version of that.

#122145 is a good example of that.

4 Likes

Right, we should steer these people towards standard testable interfaces. That may not be cargo, there are use cases for other build systems (especially for multi-language projects).

Ouch, have those guys never heard of Unix pipes? That idea is just the wrong way to do things on so many levels. What makes more sense is to stabilise JSON output from libtest and then let whatever tool wants it consume that format.

1 Like

They don't trust crates.io will be available every time someone tries to build a package, from now forever. Crates.io promises to be, and maybe it will, but so far experience with all the other software repositories is that they had outages and old packages disappearing. Just few days ago I was trying to revive an old project and it wouldn't build because it referenced JCenter repository that has since disappeared. That's why Debian has a policy that everything needed to rebuild a package must be in the archive.

That does not need to be hard, and it isn't really hard. There is a tool that pulls a new version off crates.io and uploads it to the archive. Somebody just has to sign it, because everything in the Debian archive must be.

2 Likes

I'd say not really. It is a problem from a complex project that needs to combine multiple build systems, but it has nothing to do with supporting old versions or not wanting to update.

You misunderstand. That is an example of complex projects relying on non-stable properties of rust, which in turn introduces possibility for breakage where there shouldn't be any. That's on them of course, but it still means they're more likely to delay updating.

Sure, they could mirror everything needed to build a particular piece of software. That is a pretty good idea. That doesn't mean they need a separate debian package for each and every crate (in each and every semver version).

You instead mirror the bundle of things needed to build ripgrep for ripgrep, and the bundle of things needed for some other software with it. Yes some source may be archived twice this way, who cares. You aren't going to save on install size anyway by creating a package for each, since rust is statically linked.

Perhaps Debian has tooling so it isn't a big issue. If so, good! The point was trying to get rust to move slower is the wrong solution to a problem some distros have invented for themselves.

1 Like

This is not practical because there's lots of things that are tweaked in ways that cannot reasonably be annotated.

For example, HashMap: Debug has unsurprisingly existed for a very long time. Here's an old release with it (but new enough to have an anchor for the trait impl in rustdoc): https://doc.rust-lang.org/1.26.0/std/collections/struct.HashMap.html#impl-Debug

And in the most recent release, HashMap is obviously still Debug: https://doc.rust-lang.org/1.77.0/std/collections/struct.HashMap.html#impl-Debug-for-HashMap<K,+V,+S>

But there's a major difference there: the trait bounds. At some point they were weakened to no longer need K: Eq to call Debug. When was that? I don't know. Would we need to add new annotations for when that happened? Would we have to wait 3 years to use that? I hope not.

Similarly, what about things like Add O(1) `Vec -> VecDeque` conversion guarantee by Sp00ph · Pull Request #105128 · rust-lang/rust · GitHub ? Do we need MSRV annotations for performance promises in the documentation too?

1 Like