TL;DR: allow a crate to specify abi = "stable"
or similar in Cargo.toml
. If it exposes anything that doesn't have a defined ABI (i.e. is repr(Rust)
/extern "Rust"
), fail compilation.
This is a very unbaked sketch based on a few ideas I had in the wake of @matklad's pimpl post. This is not intended to be a short term target nor really a direct suggestion, rather a starting point for discussion on what a hyper minimal stable ABI could look like for Rust.
My proposal is that we add a new library property to the cargo metadata to indicate that a library is intended to have a stable ABI. (For this post, I use abi = "stable"
, but any key communicating a similar idea would do.) This would cause compilation of the crate to fail if any publicly exported API allows working with types that don't have a defined ABI.
The main advantage of this is that the crate can now be treated as having a stable ABI. For Rust specifically, this means two main things: linking with a version of the library compiled with a different compiler, and the ability to avoid recompiling downstream when upstream gets an update.
At a glance, this seems almost impossibly restricting. One real restriction is that we can't have any polymorphic API surface area. It would appear that we're also limited to repr(C)
types everywhere and the other limitations FFI safety implies, but that's a little too strict.
For T: Sized
, the layout of &T
is defined. That means that a function with a defined calling convention can take &T
and be ABI defined. This enables a "pimpl" pattern: expose public ways of manipulating a type that the outside world knows nothing about.
For safety, the downstream library consumer treats T
as if it were ?Sized
(but with a thin pointer) and asks upstream for any information about the type it needs at runtime (such as for executing std::mem::size_of_val::<T>()
). For additional safety, T
is always treated as having drop glue. This means that even if it doesn't, it's safe to add a member that requires dropping later without worrying about it being forgotten.
Additionally, it should be treated downstream as having the safe defaults for autotraits: !Send
, !Sync
, !Unpin
, unless explicitly implemented otherwise.
The biggest remaining hurdle (that the author sees) is that of construction. If (repr(Rust)
) can only be handled by an abi = "stable"
API by reference, then we need some way to create it by owned reference.
The answer to that is that Box<T>
already has a defined layout for T: Sized
(if I'm not mistaken) despite not being FFI safe: exactly that of just a pointer. We may want an extern "RustStable" fn
to allow the use of these "stable ABI but not C FFI safe" types (pointers to decidedly not ABI stable types) rather than overloading extern "C"
. That said, it could also be useful to say that this pattern actually is FFI safe and allow calling these functions from C FFI.
Linking would only be defined to work when
- The version of the "header" being used to link is at most as semver recent as the compiled version, and is semver compatible.
- The version of the compiler being used to link is at least as semver recent as the most recent change to
abi = "stable"
before the version of the compiler used to compile the library.
To help enforce these requirements, these versions can be embedded in the compiled artifact and checked at link time.
The final question is what level of lifetime generics do we allow? The simple solution is to only allow the elided lifetime '_
. The author believes it ok to allow whatever lifetime signatures are desired, as a Rust caller is required to link a compatible "header" and C FFI is used to dealing with complicated lifetime requirements and will benefit from the machine checked ones. This isn't a dimension we have to restrict for ABI stability, as lifetimes don't exist at runtime.
One might also raise the question of partially ABI stable crates. The author is comfortable delaying that question, especially as a wrapper crate can expose the partial API in an ABI stable manner.
Major potential downsides:
- Bigger ABI stable surface area of the language
- Exclusion of traits from ABI stable crates, as std traits don't have ABI stable interfaces
- Somewhat fixable in the language: export stable ABI wrappers around trait dispatched functions and use those
- Library workaround: make a normal crate and an ABI stable wrapper
- "minor" breaking changes become "major" breaking changes when they change ABI
- The biggest potential issue here is addition of drop glue. Hence the rule about always treating types as having drop glue.
- Requires a stable mangling scheme or manual mangled symbol names everywhere
- Language solution: guarantee v0 mangling
- Issue: moving declarations and re-exporting at the original location changes mangling
- Solution: just emit duplicated symbols for every re-export location that refer to the same object
- Library solution: manual mangled names
- The standard library can't ever have a stable ABI under this system as
extern "RustStable" fn
cannot unify withextern "Rust" fn
- Library solution: a stable ABI wrapper for the stable ABI compatible part of the standard library (which is decently small, as it requires no generic types)
- Puts a foot in the door of a "proper" "stable ABI"
Major potential upsides:
- Huge incremental compile benefit when touching simple ABI stable crate in a deep hierarchy (just recompile it rather than the world)
- Linking of (simple) precompiled Rust crates becomes somewhat practical
- Puts a foot in the door of a "proper" "stable ABI"
Notable potential extensions:
- Simple unsized types, such as slices (including
&str
)- (But be careful not to stabilize the "wrong" representation of
CStr
)
- (But be careful not to stabilize the "wrong" representation of
At a minimum, I'd like to see rustc/cargo grow enough smarts that at least in debug mode, they can notice when a crate's recompile doesn't change the public ABI, so downstream crates don't have to be recompiled. This improvement doesn't need to expose any knobs to the user. IIRC, just touching a deep dependency causes the world to be recompiled, when we could do with recompiling the one crate, noticing the ABI didn't change at all, and relinking, avoiding recompiling anything downstream.