In the past, there has been discussion about a feature that allows a global slice of elements to be built from parts, spread around a crate or spanning over multiple crates. (see From "life before main" to "common life in main" for past discussion)
The testing devex team has a need for this, and I'd like to start experimenting with this. The following is a proposal I wrote for `#[distributed_slice]` aka a method to enumerate tests · Issue #3 · rust-lang/testing-devex-team · GitHub, which has a little more discussion.
Problem Description
#[test]
is magical.
Using the attribute, functions spread all over a crate can be "collected" in such a way that they can be iterated over.
However, the ability to create such a collection system, or global registration system, turns out to be useful elsewhere, not just for #[test]
.
The following are just a few examples of some large crates using alternative collection systems (inventory
, linkme
based on ctor
) for one reason or another:
pyo3
to add methods to python classes defined in Rust (to collect methods created over multipleimpl
blocks around your codebase).cucumber
, a custom testing framework (to collect test cases).typetag
, for serializing and deserializing&dyn Trait
using serde (to collect possible types to deserialize a dyn trait into).gflags
, for defining command line parameters all over your crate (to collect the definitions of command line parameters).leptos
' server fn, for marking functions that should be executed on the server, not the client (to collect all such functions).dioxus
for a very similar purpose as leptos.apollo
graphql, for registering plugins.zookeeper-client
for its sasl feature -rsasl
then uses it to register custom authentication mechanisms.bevy
has discussed having a need for this
Additionally, one can imagine webservers registring routes this way, although I found nobody doing that at the time of writing.
In almost all the examples above, doing global registration is is an opt-in feature, behind a cargo feature flag. Existing solutions are a bit of a hack, and have limited platform support.
Especially `inventory`, based on `ctor`, which most crates mentioned above use, is only regularly tested on windows, macos and linux, and use on embedded targets is complicated.
On embedded targets you must manually ensure that all global constructors are called, or a runtime like [`cortex-m-rt`](https://crates.io/crates/cortex-m-rt) must do so.It seems, authors of libraries are wary including registration systems in their library. I conjecture because random breakages due to a bug in a downstream crate, or limited platform support is painful and limiting. Bevy has discussed exactly this, citing limited or no wasm support.
Specifically for the testing-devex team, working on libtest-next.
It was proposed by Ed Page (and in in-person conversations) that we should make #[test]
less magical so rust can fully support custom test frameworks.
This plan was explicitly endorsed by the libs team.
Custom test frameworks are useful for all kinds of purposes, like test fixtures.
Importantly, it is essential for testing on #![no_std]
.
The only way to currently do that is using #![feature(custom_test_frameworks)]
It was discussed (in-person) that this is also useful for rust for linux.
In summary:
#[test]
is a magical registration system which cannot be used for any other purpose than tests.- Libraries do seem to have a need for registration systems.
- Crates offering registration system by using the linker are in use, but it seems platform support and fragility is an issue for downstream crates.
- To advance the state of testing in the language, having access to a better supported registration system is desired.
Existing solutions
Linkme
Because this pattern is so useful,
there are libraries available in the ecosystem that try to emulate this behavior.
Primarily, there's linkme's distribted_slice
macro, by David Tolnay.
As the crate's name implies, this works by (ab)using the linker.
linkme
has had issues because it was broken on various platforms in the past.
Indeed, it has some platform specific code, though most platforms are now supported.
The crate works by creating a linker section for each distributed slice,
and placing all elements of that slice in this section.
Based on special start and end symbols that are placed at the start and end of this section,
he program can figure out at runtime how large the slice has become reconstruct it using some unsafe code.
Inventory
An alternative approach, also written by David Tolnay is inventory
, based on ctor
.
Using ctor
you can define "global construtors".
Entries in a special linker section that,
on various platforms, are executed before main is called.
The name and semantics of these sections changes per-platform,
and using them users can execute code before main.
This is wildly unsafe, as std
is not yet initialized. ctor's README.md on github starts with a large warning to be very careful not to call std
functions and to use libc
functions instead if you must.
In inventory
, these ctor
s each execute a little bit of code to register some element globally before main starts in a linked list.
#[test]
#[test]
is unique, in that it does not involve the linker at all.
Instead, the compiler collects all the marked elements and generates a slice containing all elements from throughout the crates.
Note: this is also what Custom Test Frameworks does.
This can be both an advantage and a disadvantage.
Advantages:
- It's super stable. It is guaranteed to work on any platform
- If something goes wrong, you don't get a nasty linker error, but a nice compiler error
- Because it works on any platform, it indeed could support custom test frameworks on
#![no_std]
, a part of the reason why we'd want a global registration system. - It might be possible to support during const evaluation, though comments on a recent RFC by Mara show that this can also be undesirable, as it means that all crates need to be considered together during const evaluation.
Disadvantages:
- Building a slice at compile time simply does not support registering elements loaded through a dynamic library (though there might be some ways around that: TODO). Rust's story for dynamic libraries isn't great anyway, but this would add another major blocker.
- Exactly this might make hot-patching binaries harder. There were some proposals for this floating around but it would make future implementations of this harder. Actually, that goes for this entire feature, whether supported through the linker or the compiler.
Possible alternative solutions
Keep things as-is
Having this feature only supported through downstream crates.
Providing linkme
's distributed_slice
or inventory
as part of the compiler
Either of these methods would have limitations in platform support,
but if they were also tested as part of the compiler we might be able to guarantee some sort of stability.
I'm especially wary of the ctor
based approach, but maybe linkme
isn't so bad.
It seems to support many platforms,
and even has a test of it running on cortex-m #![no_std]
.
It does require a modified linker script listing the sections used for the distributed slices.
Theoretically the compiler could automate those additions to the linker script.
However, it's unclear whether linkme
supports WASM,
and based on my own testing I don't think it does.
I'm unsure what would be required to start supporting that.
Ignoring dynamic libraries: providing distributed slices like #[test]
This is the approach https://github.com/rust-lang/rfcs/pull/3632 takes.
Their reasoning is that current similar systems don't either:
global_allocator
doesn't work with dylibs either.
Indeed, tests also don't work across dylibs.
However, that's never a concern as tests are usually crate-local and always statically compiled with the binary they're testing.
Ed page also has an opinion about this, and thinks we shouldn't worry too much dynamic linking right now, though we should check with Bevy whether it'd be benificial for them.
Personally, I do think we could keep in mind that dynamic linking exists, and we should make sure that the only possible implementation of a design is not to support dylibs at all.
A hybrid approach: a proposal to move forward
I think there is a hybrid approach we can take. One that does not completely rule out dylibs, but might initially not support them while still meeting most people's needs.
The name "distributed slice" might not be very accurate. With global registration, the ordering of elements is not important, and essentially deterministically random. It's more like a distributed set of elements actually, where the index of elements in the slice is essentially useless.
Instead, I propose to expose a registration system as an opaque type that implements IntoIterator
, just like std::env::Args.
Initially, we can choose to not even expose a len
method, as the lenghth might depend on the number of dynamic libraries loaded.
The implementation could then be a slice, or a linked list, or a collection of slices linked together (one per dylib?).
Crucially, the key here is that in this way, we expose the minimal useful API for global registration,
leaving our options for implementation details completely open, such that we can change the internals of it at any point in the future.
The only downside of this approach that I could find sofar is that iterators are not const-safe (yet). Whether we want to support iteration over globally registered elements in const context is questionable (as highlighted above; then const evaluation depends on all the crates are being compiled and might register elements), but it would restrict that feature. I believe that's acceptable, especially now for experimentation, and where any linker-based approaches wouldn't support that use case either.
We should also make sure that we only implement traits for this opaque type that stay compatible with slices, so we're free to expose a slice in the future if we want to.
I'd like to experiment with that approach, to see if it meets enough people's needs.
If not we can consider one of the other approaches highlighted.
I propose calling the feature global_registration
, not distributed_slice
to be more generic.