A Rust SDK for calling other Logos modules from within a Logos module. It consumes the language-neutral lp_* C ABI from logos-protocol directly, and — together with logos-module-builder and its logos-lidl-gen code generator — gives you typed, generated clients for every module you depend on: typed sync/async calls and typed event subscriptions, with no hand-written IPC.
When you write a Logos module in Rust, everything that crosses the module boundary is generated from your .lidl contracts: the module-impl C ABI scaffold your code plugs into, and a typed client for each module you depend on. This SDK is the runtime those generated clients are built on — it binds the lp_* C ABI, serializes parameters, drives the callback trampolines, and delivers results over channels.
In day-to-day module code you rarely name a type from this crate directly. You call modules().<dep>.<method>(...) (or its _async twin), subscribe with on_<event>(), and read your module's context with context() — all generated. The SDK is what makes those calls work.
The SDK is a pure Rust rlib with no build-time C dependency. On the standard authoring path (a cdylib module built by logos-module-builder, interface = "cdylib") the lp_* symbols resolve against the logos-protocol archive already linked into the plugin — one protocol stack shared by the generated Qt glue and your Rust code.
You don't add this SDK to a Cargo.toml by hand or write a build.rs. A Rust module is:
- a
.lidlcontract (or a Rust trait the builder derives one from), - an impl of the generated trait, plus a one-line
logos_module_install()hook, - a
metadata.jsonwhosecodegen.rustblock tells the builder to run the Rust generator and compile your crate.
logos-module-builder then supplies this SDK as your crate's logos-rust-sdk dependency (matched to the generator it ran, so there is no version skew), runs logos-lidl-gen to emit the scaffold (generated/provider_gen.rs), compiles the crate to a static archive, and links it into the plugin. Your flake.nix and CMakeLists.txt end up as small as a C++ module's.
The complete, runnable walkthrough — a Rust provider, a C++ provider, and a Rust consumer wired together — is the executable doc-test doctests/cross-language-composition.test.yaml.
Everything in this section is generated by the builder from the contracts and emitted into your crate. This is the entire cross-module surface you write against — every call is typed; there is no string-keyed dispatch in module code.
A module you list in dependencies gets a typed client on the modules() aggregate, reached by its module name. The host auto-loads it with you.
impl MyModule for MyImpl {
// Synchronous typed call.
fn total(&mut self) -> i64 {
modules().counter_module.increment(1).unwrap_or(-1)
}
// Asynchronous twin: returns immediately; the typed result is delivered to
// the callback on the module's event loop, after this method returns.
fn bump_async(&mut self) {
modules().counter_module.increment_async(1, |res| {
if let Ok(v) = res { /* stash v; read it back from a later method */ }
});
}
}Code against a contract shape (declared in interface_dependencies) and bind it to a concrete provider chosen at runtime — no build-time coupling to any particular module:
fn hello(&mut self, name: String) -> String {
greeter::GreeterClient::bind("cpp_greeter_module")
.greet(&name)
.unwrap_or_else(|e| format!("greet failed: {}", e))
}Every call has a timeout; by default it is the protocol's own, 20 seconds. To
bound one call more tightly, call its _with_timeout twin. Each generated method
has one, on both surfaces:
pub fn fetch(&self, sym: &str) -> Result<Quote, LogosError>;
pub fn fetch_with_timeout(&self, sym: &str, timeout: Duration) -> Result<Quote, LogosError>;
pub fn fetch_async<F>(&self, sym: &str, callback: F);
pub fn fetch_async_with_timeout<F>(&self, sym: &str, timeout: Duration, callback: F);The bound belongs to the call, not to the client, so one client can serve calls with entirely different expectations — which is the normal case, since how long is too long is a fact about the method:
fn refresh(&mut self) -> String {
let oracle = modules().price_oracle;
let spot = oracle.fetch_with_timeout("ETH", Duration::from_millis(500)); // ~500ms
let hist = oracle.backfill_with_timeout(30, Duration::from_secs(10)); // ~10s
let other = oracle.fetch("BTC"); // 20s default
format!("{:?} {:?} {:?}", spot, hist, other)
}Nothing is stored: the duration is threaded down to lp_invoke's timeout_ms
argument for that one call, so a bound cannot leak into a later call that did not
ask for one.
The Duration is converted to the ABI's millisecond c_int at the boundary and
refused rather than clamped if it does not fit — sub-millisecond (which the
ABI would read as "use the default", i.e. 20s) and anything past c_int::MAX ms
(~24.8 days) both yield LogosError::InvalidTimeout at the point the bad value
was supplied. On the async twins that error is delivered to the callback, the way
every other undispatchable async call is reported.
Underneath, PluginProxy offers the same shape for hand-written calls:
call_with_timeout, call_with_params_with_timeout, call_sync_with_timeout,
call_json_with_timeout, call_json_async_with_timeout.
This duplicated surface is a stopgap. Rust has neither overloading nor default arguments, so a
timeoutparameter onfetchitself would break every existing call site. A later breaking release will make the timeout a first-class parameter of the one entry point per method and drop the twins; until then the pair is deliberate, and mirrors what the C++ SDK did forfooAsyncResult.
Each event in a dependency's contract generates a typed subscription. The returned EventSubscription owns its client share, so it keeps receiving after the proxy is dropped — move it into a listener thread and iterate it:
let mut calc = modules().rust_calc_module;
if let Ok(sub) = calc.on_computed() {
std::thread::spawn(move || {
for ev in sub {
if let Some(e) = rust_calc_module::RustCalcModuleClient::decode_computed(&ev) {
// e.total — the decoded, typed payload
}
}
});
}The host stamps each loaded instance with its identity. Read it through the generated context():
fn whereami(&mut self) -> String {
match context() {
Some(c) => format!(
"module_path={} | instance_id={} | persistence={}",
c.module_path, c.instance_id, c.instance_persistence_path
),
None => "context not ready".to_string(),
}
}instance_persistence_path is a writable, per-instance directory — the place to keep a module's on-disk state.
The handful of SDK types that surface directly in module code:
on_<event>() returns an EventSubscription: a Send handle bundling the event
channel with ownership of the lp subscription and a share of the client (dropping
a bare proxy would otherwise silently kill the subscription). It supports
recv(), try_recv(), blocking iteration (for ev in sub), and unsubscribes on
drop. Each item is an EventData, which the generated decode_<event>() turns
into the typed payload struct:
pub struct EventData {
pub event: String, // event name
pub data: serde_json::Value, // raw payload as JSON — use decode_<event>() for the typed form
}Every typed call and subscription returns Result<_, LogosError>:
| Variant | Cause |
|---|---|
PluginCallFailed |
Method call returned an error from the remote module |
EventListenerFailed |
Event registration failed |
InvalidTimeout |
A _with_timeout duration the protocol ABI cannot express (sub-millisecond, or > ~24.8 days) |
InvalidString |
A string argument contained a null byte |
JsonError |
Parameter serialization failed |
ChannelClosed |
The callback channel was dropped unexpectedly |
Other |
Miscellaneous error with a descriptive message |
logos-rust-sdk declares extern "C" bindings to the lp_* protocol functions
but links no C library at Rust compilation time. The crate compiles to a
staticlib containing unresolved lp_* references, which resolve at the final
plugin link:
librust_my_module.a (your Rust staticlib, contains unresolved lp_* refs)
↓
CMake links plugin .dylib
+ logos-protocol archive ← lp_* resolved here (one shared stack
↓ with the generated Qt glue)
my_module_plugin.dylib (complete, loadable Logos module)
The builder's cmake/LogosModule.cmake stages and links the archive from
codegen.rust automatically — the author writes no link lines.
The SDK itself has no standalone Nix build artifact — it is a library crate consumed by module builds. To work on it:
# Enter a dev shell with Rust toolchain
nix develop
# Run unit tests (params serialization, etc.)
cargo testTwo complementary checks exercise the SDK and the Rust-module pipeline it builds on (the cross-language composition showcases — typed calls and events crossing the C++/Rust boundary in both directions — live in logos-module-builder's doctests, since they exercise the builder across both SDKs):
-
IPC integration test (
tests/) — builds a minimal provider + caller module pair on the cdylib authoring path: each fixture is a.lidlcontract from whichlidl-gen --providergenerates the Rust module-impl C ABI scaffold (logos_module_*exports, typed trait,RustModuleContext) and logos-module-builder (interface = "cdylib") generates the uniform Qt glue. The author writes the trait impl plus a#[no_mangle] fn logos_module_install()hook; the plugin links one logos-protocol stack shared by the glue and the SDK, so the host token forwarded throughlogos_module_accept_tokenauthenticates the caller's outboundadd()call. Verified end-to-end throughlogoscore:nix build 'path:./tests#checks.x86_64-linux.ipc-test' \ --override-input logos-rust-sdk path:. --print-build-logs -
Executable doc-test (
doctests/cross-language-composition.test.yaml) — a step-by-step, runnable tutorial that writes three modules from scratch on the builder-driven cdylib path (nobuild.rs): a Rust provider, a C++ provider, and a Rust consumer that ties them together. It packages each as an.lgx, installs them withlgpm, and drives the consumer through alogoscoredaemon to exercise the whole consumer surface — module context (module_path/instance_id/instance_persistence_path), sync and async typed calls, typed event subscription, and both a concrete and an interface dependency. Run it with the shareddoctestCLI:cd doctests && ./run.sh
The rendered tutorial is committed at
doctests/outputs/cross-language-composition/cross-language-composition.md.