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 Arc;
use ;
;
The caller still passes an owned configuration:
# use ;
# use Arc;
#
# ;
#
# async
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 Arc;
use ;
;
;
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 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 BoxFuture;
use 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:
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.await?;
match handle.status
let mut statuses = handle.subscribe;
handle.wait_active.await?;
handle.reload.await?;
handle.retry.await?;
handle.dispose.await?;
Events and queries
ctx.?;
ctx.emit.await?; // serial, registration order
ctx.parallel.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
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.
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>()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().