Dependency ranges, workspaces, and breaking changes

I maintain the tls-listener crate.

When tokio-rustls 0.26 was released, I wanted to add support for it to my library, but my library only uses a small part of the API surface, and the breaking changes in that release don't directly affect my library. However, those changes likely would impact anyone who used tls-listener. But I thought that was fine, since the user could just explicitly specify in their crate what version of tokio-rustls they wanted to use, and cargo would then use that version for tls-listener's dependency as well.

Except, that doesn't work in workspaces. See supporting range of breaking change versions for tokio-rustls causes dependency hell Β· Issue #52 Β· tmccombs/tls-listener Β· GitHub

If you have a workspace where one crate (A) depends on tokio-rustls 0.26 and tls-listener, and another crate (B) depends on tokio-rustls 0.25, then the tls-listener might end up depending on version 0.25 instead of the intended 0.26 in crate A.

This leaves my tls-listener crate in an awkward position. In order to support users who use workspaces, I either have to limit the depency version range to not cross any breaking changes, regardless of if those changes directly impact my crate (i.e. any single version can support 0.25 or 0.26, but not both), or use some kind of feature flag to determine which version to depend on. Somehow.

I'm not really sure if this is a bug, or a feature request, but I think something should be done to fix this.

Some possible ways that I can think of that this situation could be improved:

  • If a crate A has a direct dependency on crates B and C, and crate B also has a dependency on crate C, and the version ranges for both dependencies on C are compatible, than always use the same version of C for the dependencies in both A and B, regardless of if another crate in the workspace uses a different version of C. I think this should be backwards compatible, and would solve this problem. I can't see any obvious downsides, but I could be missing something.
  • Have a way to explicitly specify that a dependency of a dependency must satisfy an additional range (effectively the intersection of the range defined by the crate, and the specified range). For example, in the case above, crate A could specify in the Cargo.toml file, that tls-listener's dependency on tokio-rustls must satisfy the range 0.26 in addition to the range given in tls-listener's Cargo.toml. This is more work for the user of the crate, but is potentially more flexible.
  • If a dependency A has a dependency on B, and there are at least two versions of B required by other dependencies involved in the workspace that satisfy the contraints A requires, then always choose the newest such version. This would solve the problem described above, but wouldn't solve other similar situations, where the version needed isn't the newest one. And I'm not sure if it is backwards compatible.

See also bugs/Cargo.toml at tls-listener/dependency-hell-52 Β· TheButlah/bugs Β· GitHub for a minimal reproducible example of this, and mismatch in version of tokio-rustls causes zenoh to not compile Β· Issue #1747 Β· eclipse-zenoh/zenoh Β· GitHub for an example "in the wild".

I'm not sure if this is something that should be addressed by an RFC or just a Github bug report or feature request.

Maybe having an optional dependency on both tokio-rustls versions behind separate features would work? You can use something like tokio-rustls-025 = { package = "tokio-rustls", version = "0.25" } to add a dependency on tokio-rustls 0.25 and have it accessiblr as tokio_rustls_025 in code.

I could, but in order to handle the case of both features being enabled, I would have to duplicate the code for both versions, and while impl blocks will just work, any structs or functions that refer to tokio-rustls will need to use different names depending on version.

And regardless of whether I support enabling both features at once, it would be a breaking change, since users would have to specify which version they want with a feature flag.

I don't think there is a way around either having to release a new major version of tls-listener or having a single tls-listener version that can handle multiple tokio-rustls versions at the same time.

Workspaces don't seem to be relevant, you can end up with the same issues with just normal dependencies

top-level
β”œβ”€β”€ library-a
β”‚   β”œβ”€β”€ tls-listener
β”‚   └── tokio-rustls-0.26
└── library-b
    β”œβ”€β”€ tls-listener
    └── tokio-rustls-0.25

library-a and library-b can independently use tls-listener with their version of tokio-rustls; but as soon as the two are combined into one build there's no way to unify the implicit mutually exclusive dependency choice tls-listener provides. bjorn3's suggestion to have explicit non-exclusive features is how you have to encode options like this in cargo's build graph.

Thinking of multiple-breaking-range spanning version requirements as a set of mutually exclusive features really highlights how badly they fit into cargo's system. The only ways I can see to correctly use them are:

  • Absolutely no public API involvement, they're hidden internally so choosing one or the other doesn't impact dependents at all.
  • Declare explicitly that dependents may not depend on the dependency themselves. They must only access it through the reΓ«xport and must only use a known subset of the dependencies public API (so that you know what parts of the API to check when expanding the version range in the future).
2 Likes

In my case, the first isn't possible.

And the second is problematic, because then the user could run into the backwards incompatible changes, depending on what version tokio-rustls resolves to.

Major-open semver range does not properly unify with closed semver ranges Β· Issue #9029 Β· rust-lang/cargo Β· GitHub is likely the relevant issue for dependency resolution itself.

Also, a future possibility of the public/private deps RFC is the ability to say "auto-select dep X's version from dep Y's public dependency".

1 Like