Skip to content

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 listvec![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.