Skip to content

Repository files navigation

logos-rust-sdk

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.

Overview

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.

The authoring model — no build.rs

You don't add this SDK to a Cargo.toml by hand or write a build.rs. A Rust module is:

  • a .lidl contract (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.json whose codegen.rust block 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.

Calling other modules

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.

Concrete dependencies — modules()

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 */ }
        });
    }
}

Interface dependencies — Client::bind(name)

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))
}

Per-call timeouts — <method>_with_timeout(...)

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 timeout parameter on fetch itself 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 for fooAsyncResult.

Typed events — on_<event>() / decode_<event>()

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
            }
        }
    });
}

Module context — context() / RustModuleContext

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.

Supporting types

The handful of SDK types that surface directly in module code:

EventSubscription and EventData

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
}

LogosError

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

How symbols resolve

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.

Building

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 test

Testing

Two 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 .lidl contract from which lidl-gen --provider generates 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 through logos_module_accept_token authenticates the caller's outbound add() call. Verified end-to-end through logoscore:

    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 (no build.rs): a Rust provider, a C++ provider, and a Rust consumer that ties them together. It packages each as an .lgx, installs them with lgpm, and drives the consumer through a logoscore daemon 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 shared doctest CLI:

    cd doctests && ./run.sh

    The rendered tutorial is committed at doctests/outputs/cross-language-composition/cross-language-composition.md.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages