# 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.0.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
| `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`.
```rust
use std::sync::Arc;
use cordis_core::{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:
```rust
# use cordis_core::{App, Result};
# use std::sync::Arc;
# struct FeatureConfig { greeting: String }
# struct FeaturePlugin;
# impl cordis_core::Plugin for FeaturePlugin {
# type Config = FeatureConfig;
# fn name(&self) -> &'static str { "feature" }
# async fn apply(&self, _: cordis_core::PluginContext, _: Arc<Self::Config>) -> Result<()> { Ok(()) }
# }
# async fn example() -> Result<()> {
let app = App::new();
let plugin = app.install(
FeaturePlugin,
FeatureConfig { greeting: "hello".into() },
).await?;
plugin.wait_active().await?;
# plugin.dispose().await?;
# Ok(())
# }
```
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:
```rust
use std::sync::Arc;
use cordis_core::{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:
```rust
use std::sync::Arc;
use cordis_core::{App, Plugin, PluginContext, Result, ServiceHandle, ServiceKey};
struct ConfigKey;
impl ServiceKey for ConfigKey {
type Value = usize;
const NAME: &'static str = "config";
}
struct Provider;
impl Plugin for Provider {
type Config = Arc<tokio::sync::Mutex<Option<ServiceHandle<ConfigKey>>>>;
fn name(&self) -> &'static str {
"provider"
}
async fn apply(&self, ctx: PluginContext, slot: Arc<Self::Config>) -> Result<()> {
let handle = ctx.provide::<ConfigKey>(Arc::new(1))?;
*slot.lock().await = Some(handle);
Ok(())
}
}
# fn main() {
# tokio::runtime::Runtime::new().unwrap().block_on(async {
let app = App::new();
let slot = Arc::new(tokio::sync::Mutex::new(None));
let provider = app.install(Provider, slot.clone()).await.unwrap();
provider.wait_active().await.unwrap();
let guard = slot.lock().await;
let handle = guard.as_ref().unwrap();
handle.replace(Arc::new(2)).unwrap(); // new generation; dependents reload
handle.touch().unwrap(); // same value, new generation
handle.remove(); // dependents reconcile
drop(guard);
provider.dispose().await.unwrap();
# });
# }
```
Dropping 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:
```rust
use futures::future::BoxFuture;
use cordis_core::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.
```text
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 -> Disposed
```
Declare dependencies on the plugin:
```rust
use std::sync::Arc;
use cordis_core::{App, Dependency, Plugin, PluginContext, PluginStatus, Result, ServiceKey};
struct DatabaseKey;
impl ServiceKey for DatabaseKey {
type Value = usize;
const NAME: &'static str = "database";
}
struct CacheKey;
impl ServiceKey for CacheKey {
type Value = usize;
const NAME: &'static str = "cache";
}
struct Feature;
impl Plugin for Feature {
type Config = ();
fn name(&self) -> &'static str {
"feature"
}
fn dependencies(&self) -> Vec<Dependency> {
vec![
Dependency::required::<DatabaseKey>(),
Dependency::optional::<CacheKey>(),
]
}
async fn apply(&self, ctx: PluginContext, _config: Arc<Self::Config>) -> Result<()> {
// optional services may be missing at activation time
let _cache = ctx.try_get::<CacheKey>();
ctx.get::<DatabaseKey>()?;
Ok(())
}
}
# fn main() {
# tokio::runtime::Runtime::new().unwrap().block_on(async {
let app = App::new();
let handle = app.install(Feature, ()).await.unwrap();
// required `database` is missing -> Suspended; optional `cache` is tolerated
assert!(matches!(
handle.status(),
PluginStatus::Suspended { missing } if &*missing == ["database"]
));
# });
# }
```
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`.
```rust
use std::sync::Arc;
use cordis_core::{App, Plugin, PluginContext, PluginStatus, Result};
struct MyPlugin;
impl Plugin for MyPlugin {
type Config = ();
fn name(&self) -> &'static str {
"my-plugin"
}
async fn apply(&self, _ctx: PluginContext, _config: Arc<Self::Config>) -> Result<()> {
Ok(())
}
}
# fn main() {
# tokio::runtime::Runtime::new().unwrap().block_on(async {
let app = App::new();
let handle = app.install(MyPlugin, ()).await.unwrap();
match handle.status() {
PluginStatus::Suspended { missing } => println!("missing: {missing:?}"),
PluginStatus::Failed { message, .. } => eprintln!("failed: {message}"),
_ => {}
}
let mut statuses = handle.subscribe();
handle.wait_active().await.unwrap();
handle.reload().await.unwrap();
assert!(statuses.changed().await.is_ok()); // reload moved through Stopping/Starting
handle.retry().await.unwrap();
handle.dispose().await.unwrap();
# });
# }
```
## Events and queries
```rust
use std::sync::Arc;
use cordis_core::{App, Plugin, PluginContext, Result};
#[derive(Debug)]
struct Message {
text: &'static str,
}
struct Chat;
impl Plugin for Chat {
type Config = ();
fn name(&self) -> &'static str {
"chat"
}
async fn apply(&self, ctx: PluginContext, _config: Arc<Self::Config>) -> Result<()> {
ctx.on::<Message, _, _>(|_ctx, message| async move {
println!("received: {}", message.text);
Ok(())
})?;
Ok(())
}
}
# fn main() {
# tokio::runtime::Runtime::new().unwrap().block_on(async {
let app = App::new();
let handle = app.install(Chat, ()).await.unwrap();
handle.wait_active().await.unwrap();
let ctx = app.context();
ctx.emit(Message { text: "serial" }).await.unwrap(); // serial, registration order
ctx.parallel(Message { text: "parallel" }).await.unwrap(); // concurrent
handle.dispose().await.unwrap();
# });
# }
```
Ignoring `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 `apply` rolls 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`.
## Run the examples
Runnable examples live under `examples/`:
```bash
cargo run --example minimal # provider + consumer with dependency injection
cargo run --example suspension_reload # suspend/activate/reload/replace lifecycle
cargo run --example events_queries # serial/parallel events, first-answer queries
cargo run --example tasks_resources # cooperative tasks and managed resources
cargo run --example extension_trait # trait-object services and extension traits
cargo run --example isolation_lifecycle # isolate(), Ready/Fork/Dispose events
```
## 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.
```bash
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build --examples
cargo test --all-features
cargo test --doc
```
More implementation detail is documented in
[`docs/suspension-reload.md`](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>()` and `PluginContext::isolate::<K>()` create a fresh
typed slot for `K` while inheriting all other services.
- `Ready`, `Fork`, and `Dispose` are typed lifecycle events.
- `PluginHandle::diagnostics()` returns timestamped status history.
- `ErasedPlugin` and `App::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()`.