I like the Extensions type map from http, and recently needed the same thing in my own code. Two things got in the way: I didn't want to depend on an HTTP crate just for a type map, and inserting into http::Extensions needs &mut self. That doesn't work when the object is shared, e.g. behind an Arc and used concurrently.
So I wrote OnceTypeMap: think of it as a OnceLock per type. Values are set through &self (get_or_init, insert) and returned as plain &T tied to the borrow of the map, with no lock guard, so an owning struct can still expose fn foo(&self) -> &Foo.
- Repo: GitHub - noclue/once_type_map: A thread-safe, insert-once type map that hands out plain `&T` references · GitHub
- Crate: crates.io: Rust Package Registry (0.1.1)
- Docs: once_type_map - Rust
What I'd like feedback on
- Soundness of the
unsafe. Is the reasoning below sound? Is anything missing? - Prior art. Is there an existing crate I should have used instead? I found
http::Extensions, which needs&mutfor inserts, so it does not fit shared caches. I'd also like to hear about anything that gives&Tfrom&self. I looked at state and it lacksget_or_inituseful for lazy init. - Fallible and async initialization. I left these to the stored value: store a
Result, aonce_cell::sync::OnceCell(forget_or_try_init), or atokio::sync::OnceCell. There is an example for each. Is that a reasonable split, or should the map offer them itself?
My use case. I'm building several SDK-like crates on top of a shared Client from a foundation crate. The Client handles connectivity and auth for all of them, but each SDK needs to cache facts about the connection that the Client doesn't know about (API version, handles to remote objects, and so on). With OnceTypeMap, each crate attaches that state through an extension trait. See examples/extensions.rs.
The unsafe. This is my first crate with unsafe code. All of it goes through one private function, detach, which extends a slot reference from the lock guard's lifetime to the borrow of the map. The argument:
- Each value lives in its own heap-allocated
OnceLock<T>slot. Through&selfa slot can only be created and filled once. It is never moved, replaced or dropped. - Removing or mutating anything (
remove,clear,get_mut) needs&mut self, and dropping or moving the map needs ownership. The borrow checker rejects all of these while a&Tfrom the map is alive. This is analogous to whyOnceLock::get_mutandtakeare safe. - The map is shared, not frozen, while a
&Tis alive. Other threads can still add new types, and the innerHashMapmay resize and move its entries. So the slots must be owned through something whose move doesn't invalidate references into the slot. I own each slot through a never-clonedArc<dyn Slot>instead of aBox, because whether moving aBoxinvalidates references into it I understand is unsettled in Rust's aliasing rules. (Miri doesn't flag theBoxversion today. I changed it to avoid ambiguity.) - The write-locked path (
create_slot) can hit an already-occupied entry if another thread won the race, and it only hands out a&mutto theArc, not to theOnceLockinside.
CI runs Miri under both Stacked Borrows and Tree Borrows, including a test that keeps a &T alive while every slot is moved through typed code.
Known limitations: one value per type (use newtypes), T: Send + Sync + 'static, and calling get_or_init::<T> from inside the initializer for the same T deadlocks (as with OnceLock). Cyclic dependencies between initializers do too. Both are documented.
Disclosure. I wrote the requirements and a first draft of the code. LLMs helped a lot with the rest: polishing the library, writing tests and docs, writing the examples, and working through the safety argument. I've reviewed all of it, and that is why I am asking for human eyes on the unsafe part. The design isn't very original: it is influenced by OnceLock and http::Extensions. The MSRV is Rust 1.86, because I use trait upcasting (dyn Slot to dyn Any) instead of hand-written as_any helpers.
1 post - 1 participant
