cordis-core 0.0.4

A typed, scope-based plugin runtime inspired by Cordis
Documentation

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

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_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:

# 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:

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:

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:

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.

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:

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.

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

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/:

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.

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.

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().