Why registration happens at link time¶
The mechanism¶
BUILTIN_COMMANDS is a linkme distributed slice. Each
crate that wants to offer a command marks a factory function with an attribute:
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_deploy() -> Box<dyn Command> { Box::new(Deploy) }
The macro places that function pointer in a dedicated linker section. At link time the
linker gathers every such entry into one contiguous array, and BUILTIN_COMMANDS
becomes a plain &'static [fn() -> Box<dyn Command>] pointing at it. By the time
main runs, the slice is fully populated and immutable.
BUILTIN_PRERUN_HOOKS works identically for the pre-dispatch hooks.
What the alternatives cost¶
A hand-maintained list — vec![Box::new(Deploy), Box::new(Update), …] somewhere
central — makes adding a command a two-place edit, and the second place is in a
different crate from the one you are working in. It is the kind of step that gets
forgotten, and forgetting it produces no error.
A runtime registry — a Lazy<Mutex<HashMap<&str, …>>> populated by constructors
running before main — needs a lifecycle-before-main mechanism Rust does not have,
or an explicit register_all() call that reintroduces the central list. It also puts
a mutex on a path that is read once and never written, and makes the registry mutable
long after it should have been frozen.
Link-time registration avoids both. There is nothing to remember, nothing to lock, no initialisation order to reason about, and no state that can change after startup.
What it costs¶
linkme has to be a direct dependency of the registering crate. The attribute
expands to absolute ::linkme::… paths, and ::linkme resolves against the crate's
own extern prelude — which a re-export through rtb-app does not populate. The error
is cannot find linkme in the crate root, pointing at a line that mentions no
linkme. rtb-app does re-export linkme, and the re-export is usable, but only
with an extra #[linkme(crate = rtb_app::linkme)] attribute telling the macro where
to look. Register a command covers both routes.
Order is not defined. The linker decides. Nothing in the toolchain promises which
crate's entries come first, and the ordering can change between builds. Both slices
document this, and it has a consequence worth stating: the "register a command with
the same name to override a built-in" pattern depends on a deduplication rule in
rtb-cli resolving a collision, not on any ordering rtb-app guarantees. If you use
it, test that the command you expect is the one that runs.
A missing registration is silent. Forget the attribute and everything still
compiles, and a test that calls your command directly still passes. The command
simply is not there. There is no compile-time check that a Command
implementation was registered, so the only defence is a test asserting the name
appears in BUILTIN_COMMANDS.
The slice is process-wide, and tests share it. Every test binary that links a
crate registering commands sees every one of them, including commands registered by
other test files in the same binary. A test asserting "there are exactly three
commands" is fragile for this reason; assert on contains rather than on length.
Why factories rather than values¶
The slice holds fn() -> Box<dyn Command>, not &'static dyn Command. A factory can
be a plain function pointer with no initialiser to run at load time, and it lets the
consumer decide when — and how many times — a command object is built. The contract is
that a factory is cheap: no I/O, no allocation beyond the box. The work belongs in
Command::run.