Expand description
§cordis-rs
A typed, scope-based asynchronous plugin runtime inspired by Cordis.
- MSRV: Rust 1.85
- Edition: Rust 2024
- Status: production-core complete; public API remains pre-1.0 (
0.2.x)
The public API keeps Cordis’ context, service, event, plugin and automatic
cleanup model, while replacing Proxy/string-key behavior with Rust types,
Arc, native async traits and explicit activation state.
§Core mapping
| Cordis / TypeScript | cordis-rs |
|---|---|
ctx.database | ctx.get::<DatabaseKey>()? or an extension trait |
| string service key | ServiceKey (TypeId internally) |
| declaration merging | framework-specific extension traits |
| string event | concrete Rust event type |
inject: ['database'] | Dependency::required::<DatabaseKey>() |
| optional injection | Dependency::optional::<CacheKey>() |
| disposer function | activation-owned reverse async cleanup stack |
| plugin fork | persistent PluginHandle + replaceable activation |
| service epoch | per-service generation + dependency snapshot |
§Plugin API
Plugin and Resource use native Rust async methods. Plugin authors do not
need async-trait.
use std::sync::Arc;
use cordis_rs::{Dependency, Plugin, PluginContext, Result};
struct FeatureConfig {
greeting: String,
}
struct FeaturePlugin;
impl Plugin for FeaturePlugin {
type Config = FeatureConfig;
fn name(&self) -> &'static str {
"feature"
}
fn dependencies(&self) -> Vec<Dependency> {
Vec::new()
}
async fn apply(
&self,
ctx: PluginContext,
config: Arc<Self::Config>,
) -> Result<()> {
println!("{}", config.greeting);
ctx.defer(|| async {
println!("feature activation disposed");
Ok(())
})?;
Ok(())
}
}The caller still passes an owned configuration:
let app = App::new();
let plugin = app.install(
FeaturePlugin,
FeatureConfig { greeting: "hello".into() },
).await?;
plugin.wait_active().await?;Internally the configuration becomes Arc<Config>, so reload works without
requiring Config: Clone.
§Typed services
A key type describes the value type independently of its runtime name:
use std::sync::Arc;
use cordis_rs::{Plugin, PluginContext, Result, ServiceKey};
trait Database: Send + Sync {
fn name(&self) -> &'static str;
}
struct DatabaseKey;
impl ServiceKey for DatabaseKey {
type Value = dyn Database;
const NAME: &'static str = "database";
}
struct DatabasePlugin;
impl Plugin for DatabasePlugin {
type Config = Arc<dyn Database>;
fn name(&self) -> &'static str { "database-provider" }
async fn apply(
&self,
ctx: PluginContext,
database: Arc<Self::Config>,
) -> Result<()> {
// Config is Arc<Arc<dyn Database>> here; clone the inner Arc.
ctx.provide::<DatabaseKey>(database.as_ref().clone())?;
Ok(())
}
}A service registration is staged during apply. It becomes externally visible
only after apply succeeds. The providing activation can see its own staged
service. Listener and query registrations are staged in the same way.
ServiceHandle<K> supports:
handle.replace(new_value)?; // new generation; dependents reload
handle.touch()?; // same value, new generation
handle.remove(); // dependents reconcileDropping a handle does not remove the registration; the activation owns it.
§Async trait-object services
Native async fn in traits is not dyn-compatible on Rust 1.85. A service used
as dyn Database should expose BoxFuture, or let the service crate choose
async-trait itself:
use futures::future::BoxFuture;
use cordis_rs::Result;
trait AsyncDatabase: Send + Sync {
fn health(&self) -> BoxFuture<'_, Result<()>>;
}The runtime itself does not depend on async-trait.
§Suspension and reload
A plugin registration is persistent; its activation is disposable.
required missing -> Suspended
required appears -> Starting -> Active
required removed -> Stopping -> Suspended
required/optional generation changed -> Stopping -> Starting -> Active
apply failed -> Failed
manual retry/reload -> Starting
handle disposed -> DisposedDeclare dependencies on the plugin:
fn dependencies(&self) -> Vec<Dependency> {
vec![
Dependency::required::<DatabaseKey>(),
Dependency::optional::<CacheKey>(),
]
}Semantics:
- missing required service suspends the plugin without running
apply; - required service appearance activates it;
- required service removal disposes the activation and suspends it;
- required service replacement/touch reloads it;
- optional appearance, removal and generation change also reload it;
- unrelated service changes do not reload it;
- a failed dependency snapshot does not spin-retry;
- dependency change or
PluginHandle::retry()can retry a failure.
install() returns a handle even when the initial state is Suspended or
Failed. Registration errors such as installing after shutdown still return
Err.
let handle = app.install(MyPlugin, config).await?;
match handle.status() {
PluginStatus::Suspended { missing } => println!("missing: {missing:?}"),
PluginStatus::Failed { message, .. } => eprintln!("failed: {message}"),
_ => {}
}
let mut statuses = handle.subscribe();
handle.wait_active().await?;
handle.reload().await?;
handle.retry().await?;
handle.dispose().await?;§Events and queries
ctx.on::<Message, _, _>(|ctx, message| async move {
Ok(())
})?;
ctx.emit(Message { /* ... */ }).await?; // serial, registration order
ctx.parallel(Message { /* ... */ }).await?; // concurrentIgnoring ListenerHandle does not unregister the listener. Use
handle.cancel() for early removal; activation disposal removes it
automatically.
Typed query handlers return Option<Response>. Queries run in registration
order and stop at the first Some or error.
§Cleanup guarantees
- registrations belong to the current activation;
- cleanup runs in reverse registration order;
- cleanup continues after errors and returns the first cleanup error;
- tasks/resources start only when the activation commits;
- task cleanup signals its
CancellationToken, waits five seconds, then aborts; - failed
applyrolls back every staged effect; - apply, reload, retry and dispose transitions are serialized;
- concurrent dispose callers share one cleanup execution and result;
- declared dependency graphs unload consumers before providers;
- plugin/task/handler/cleanup panics become ordinary runtime errors.
§Compatibility alias
PluginScope remains a type alias for PluginHandle during the early API
transition. New code should use PluginHandle.
§Implemented contract tests
Black-box tests live under tests/ and cover services, events, queries,
cleanup, tasks/resources, compile-time constraints, staging, suspension,
required/optional reload, replacement, unrelated changes, failure retry and
non-Clone configuration reuse.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --docMore implementation detail is documented in
docs/suspension-reload.md.
§Production facilities
Plugin::provides()supplies static provider metadata; cycles are rejected during registration and provider transitions quiesce transitive consumers in reverse topological order.- Service revisions are debounced for one millisecond and reconciled from the latest generation snapshot, coalescing bursts without losing final state.
Context::isolate::<K>()andPluginContext::isolate::<K>()create a fresh typed slot forKwhile inheriting all other services.Ready,Fork, andDisposeare typed lifecycle events.PluginHandle::diagnostics()returns timestamped status history.ErasedPluginandApp::install_erased()provide an object-safe registry boundary without imposing a serialization format.
§Intentional non-goals
JSON/TOML deserialization belongs to the framework above this crate. Named
runtime service qualifiers are also omitted: use distinct ServiceKey types or
typed isolation, which preserves compile-time result types. Dependency-graph
ordering requires providers to accurately implement Plugin::provides().
Structs§
- Activation
Id - App
- Root runtime and owner of every persistent plugin registration.
- Context
- Cheaply clonable access to the shared runtime.
- Dependency
- Dispose
- Fork
- Listener
Handle - Non-owning early-cancellation handle.
Dropintentionally does nothing. - Plugin
Context - Context passed to a plugin. Registrations are automatically owned by its scope and survive when their returned handles are ignored.
- Plugin
Diagnostic - Plugin
Handle - Persistent plugin registration. Its activation may be repeatedly created and disposed as dependency generations change.
- Plugin
Id - Ready
- Service
Declaration - Service
Handle - Non-owning service control handle. Dropping it does not unregister the service; ownership remains with the activation scope.
- Task
Handle
Enums§
Traits§
- Erased
Plugin - Object-safe boundary for configuration-driven registries. Serialization is deliberately left to the framework using this crate.
- Event
- Any thread-safe static value can be used as an event payload.
- Plugin
- Strongly typed plugin API. Implementations can use native
async fnon Rust 1.85+; no proc-macro is required. - Query
- A typed bail/query event.
- Resource
- Service
Key - Typed service key. The associated value may be unsized, including a trait
object such as
dyn Database.
Type Aliases§
- Erased
Config - Plugin
Scope - Backwards-compatible name for the persistent plugin registration.
- Result
- Library-wide result type.