ExtensionPoint<T>
Typed, name-indexed, hot-swappable collection with drain-aware replace.
ExtensionPoint<T>
A typed, name-indexed, hot-swappable collection.
ExtensionPoint<T> is the substrate of the composable runtime. Every pluggable category — chat providers, embedding providers, tools, context adapters, session stores, execution stores, embedding stores, artifact stores, run stores, event publishers, session data stores, runtime event stores, snapshot stores — is exposed as an ExtensionPoint<dyn Trait>. Operators compose a runtime by registering implementations by name, and can replace any registered instance at runtime.
The full file is src/runtime/extension.rs.
Why it exists
Before ExtensionPoint<T>, every "registry" in the runtime was its own bespoke data structure: ProviderRegistry carried two HashMaps, RuntimeStore carried five trait objects, the ContextPipeline carried an IndexMap, and so on. Each one decided independently how to handle lookup, hot-swap, and live-reference detection. The behaviours diverged:
ProviderRegistry::register_chatsilently replaces the existing entry and returns the old one, but does not check whether anyone is still holding it.RuntimeStorehad no hot-swap at all — to replace a store you had to rebuild the runtime.- The context pipeline exposed an
IndexMapwhose mutation rules were the public API.
ExtensionPoint<T> unifies the four jobs every registry needs:
- Register by name with conflict detection.
- Replace atomically, returning the previous
Arc<T>. - Detect live references so that callers don't accidentally drop something still in use.
- Natural drain — callers holding the old
Arc<T>keep it alive until they drop it; newgetcalls return the new instance.
The result is a single primitive that any component — ProviderRegistry, RuntimeStore, ContextPipeline, ComponentRegistry, FactoryRegistry — can use as its internal store, gaining all four behaviours for free.
Design principles
- Name-indexed — every entry is keyed by a stable string. The same name space is shared with the component registry, so config files can reference extensions by name (e.g.
"primary","fallback-eu"). - Clonable — the inner state is wrapped in an
Arc, so cloning anExtensionPointis cheap. A clone observes every registration performed on the original. - Hot-swappable —
replaceatomically swaps the storedArc<T>and returns the previous one. Callers holding the oldArccontinue to use it; newgetcalls return the new instance. - In-use detection —
unregisterrefuses to drop an entry whose strong count is above the registry's reference (one reference for the storage slot). This catches the common bug of removing a provider that is still serving a run. - Natural drain —
replacereturns the oldArc<T>so the caller can observe when in-flight work is done (viaArc::strong_countor by awaiting known work) before recycling the old instance.
API surface
use std::sync::Arc;
use behest::runtime::extension::ExtensionPoint;
pub struct ExtensionPoint<T: ?Sized> {
// private
}
impl<T: ?Sized> ExtensionPoint<T> {
pub fn new() -> Self;
pub fn register(&self, name: impl Into<String>, value: Arc<T>) -> Result<(), ExtensionError>;
pub fn register_or_replace(&self, name: impl Into<String>, value: Arc<T>) -> Option<Arc<T>>;
pub fn get(&self, name: &str) -> Option<Arc<T>>;
pub fn names(&self) -> Vec<String>;
pub fn is_empty(&self) -> bool;
pub fn len(&self) -> usize;
pub fn unregister(&self, name: &str) -> Result<Option<Arc<T>>, ExtensionError>;
pub fn replace(&self, name: &str, new: Arc<T>) -> Result<Arc<T>, ExtensionError>;
}
impl<T: ?Sized> Default for ExtensionPoint<T> { ... }
impl<T: ?Sized> Clone for ExtensionPoint<T> { ... } // cheap, Arc-backed
Errors
pub enum ExtensionError {
AlreadyRegistered { name: String },
NotFound { name: String },
InUse { name: String, strong_count: usize },
LockPoisoned,
}
The variants are #[non_exhaustive]; exhaustive matching requires a wildcard arm.
Behaviour
Register and lookup
let ep: ExtensionPoint<String> = ExtensionPoint::new();
ep.register("greeting", Arc::new("hello".to_string()))?;
assert_eq!(ep.get("greeting").map(|s| (*s).clone()), Some("hello".to_string()));
register fails with AlreadyRegistered if the name is taken. register_or_replace is the form most runtime code uses — it returns the previous Arc<T> so the caller can decide what to do with it.
In-use detection
let ep: ExtensionPoint<String> = ExtensionPoint::new();
ep.register("k", Arc::new("v".to_string()))?;
let external = ep.get("k").unwrap(); // strong_count = 2 (storage + external)
assert!(ep.unregister("k").is_err()); // refuses; still in use
drop(external); // strong_count = 1
assert!(ep.unregister("k").is_ok()); // succeeds; storage dropped
The strong count is checked against the registry's single reference. An external Arc that is currently being awaited by a run is enough to block the unregister.
Hot-swap with replace
let ep: ExtensionPoint<String> = ExtensionPoint::new();
ep.register("k", Arc::new("old".to_string()))?;
let old = ep.replace("k", Arc::new("new".to_string()))?;
assert_eq!(*old.unwrap(), "old");
assert_eq!(*ep.get("k").unwrap(), "new");
replace is synchronous and atomic at the level of the storage map. The old Arc is handed back so the caller can observe when in-flight work is done before recycling the old instance. replace does not check whether the old Arc is still in use — that's the caller's responsibility. For the full drain story, see Drain-aware Replace.
Worked example
use std::sync::Arc;
use std::time::Duration;
use behest::runtime::extension::ExtensionPoint;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Typed extension point for an opaque "metrics sink" trait object.
let ep: ExtensionPoint<dyn Send + Sync> = ExtensionPoint::new();
// Bring up v1.
let v1: Arc<dyn Send + Sync> = Arc::new("v1-metrics");
ep.register("primary", v1.clone())?;
// A long-running task holds a reference to v1.
let inflight = ep.get("primary").unwrap();
let task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
// drop the held Arc at the end of the task
drop(inflight);
});
// Hot-swap. `replace` returns the old Arc<T>; new `get` calls return v2.
// The old instance stays alive until `old` (and the task's clone) are dropped.
let v2: Arc<dyn Send + Sync> = Arc::new("v2-metrics");
let old = ep.replace("primary", v2)?;
task.await?; // task drops its clone of v1
drop(old); // we drop our reference; v1 is now reclaimed
println!("swap complete");
Ok(())
}
Edge cases & error semantics
- Register race — two threads calling
register("k", …)concurrently: exactly one succeeds; the other getsAlreadyRegistered. There is no deadlock. - Replace missing —
replaceon a name that was never registered returnsNotFound. Ifunregister("k")runs between yourget("k")and yourreplace("k", …), thereplacealso returnsNotFound. - Lock poisoning — if a panic occurs while holding the inner
RwLock, subsequent calls returnLockPoisoned. This is rare in practice because the inner lock is only held for hash-map operations, but callers should still surface the error. - Dropping the last
Arc<T>— happens automatically when both the storage slot and the last externalArcare dropped. There is noDropimpl forExtensionPoint<T>; the innerArc<ExtensionInner<T>>keeps the map alive as long as any clone exists. - Trait objects — the
T: ?Sizedbound allowsExtensionPoint<dyn ChatProvider>. For sized types, the common case is also supported. TheDefaultimpl produces an empty point.
Relationship to other components
ExtensionPoint<T> is the storage primitive. The other core abstractions layer on top:
Extensionsis a struct of 13ExtensionPointfields, one per category of plug-in. Every public API in the runtime reads from this struct.Componentis the lifecycle contract that pairs with anExtensionPoint<Box<dyn AnyComponent>>insideComponentRegistry.FactoryRegistryusesExtensionPointunder the hood to mapkindstrings to factory functions.- The drain protocol is documented in detail at Drain-aware Replace.
See also
- Extensions Facade — the 13-field struct of
ExtensionPoints. - Drain-aware Replace — atomic replace with natural
Arcdrain. - Component Trait — the lifecycle contract for plug-ins.
- ComponentRegistry — the orchestrator.
- FactoryRegistry —
kind→ factory mapping.