Skip to main content

Crate cordis_core

Crate cordis_core 

Source
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 / TypeScriptcordis-rs
ctx.databasectx.get::<DatabaseKey>()? or an extension trait
string service keyServiceKey (TypeId internally)
declaration mergingframework-specific extension traits
string eventconcrete Rust event type
inject: ['database']Dependency::required::<DatabaseKey>()
optional injectionDependency::optional::<CacheKey>()
disposer functionactivation-owned reverse async cleanup stack
plugin forkpersistent PluginHandle + replaceable activation
service epochper-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 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().

Structs§

ActivationId
App
Root runtime and owner of every persistent plugin registration.
Context
Cheaply clonable access to the shared runtime.
Dependency
Dispose
Fork
ListenerHandle
Non-owning early-cancellation handle. Drop intentionally does nothing.
PluginContext
Context passed to a plugin. Registrations are automatically owned by its scope and survive when their returned handles are ignored.
PluginDiagnostic
PluginHandle
Persistent plugin registration. Its activation may be repeatedly created and disposed as dependency generations change.
PluginId
Ready
ServiceDeclaration
ServiceHandle
Non-owning service control handle. Dropping it does not unregister the service; ownership remains with the activation scope.
TaskHandle

Enums§

Error
FailurePhase
PluginStatus

Traits§

ErasedPlugin
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 fn on Rust 1.85+; no proc-macro is required.
Query
A typed bail/query event.
Resource
ServiceKey
Typed service key. The associated value may be unsized, including a trait object such as dyn Database.

Type Aliases§

ErasedConfig
PluginScope
Backwards-compatible name for the persistent plugin registration.
Result
Library-wide result type.