cordis-core 0.0.2

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

# use cordis_rs::{App, Result};
# use std::sync::Arc;
# struct FeatureConfig { greeting: String }
# struct FeaturePlugin;
# impl cordis_rs::Plugin for FeaturePlugin {
#   type Config = FeatureConfig;
#   fn name(&self) -> &'static str { "feature" }
#   async fn apply(&self, _: cordis_rs::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_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 reconcile

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

Declare 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?;  // concurrent

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.

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