dbuff 0.1.0

Double-buffered state with async command chains, streaming, and keyed task pools for ratatui applications
Documentation

dbuff

Crates.io License: LGPL-3.0-or-later Repository

Lock-free shared state for ratatui applications. UI threads read snapshots instantly while background workers apply updates asynchronously.

dbuff provides SharedDomainData<D> — an ArcSwap-backed double buffer. Reads via .read() return a guard that dereferences to &D without any lock. Writes go through a channel and are batched by a coalescing worker, so the UI never blocks. On top of that, dbuff ships composable async command chains, stream consumers, and a keyed task pool — all wired to write results back into your domain data.

Concepts

The core idea: you run an async command and provide a closure that says what to do with the result. The crate handles the lifecycle including spawning the task, awaiting the output, and calling your closure when it's done.

That closure is where you decide how to signal the result to readers. The most common approach is to write it into shared state via SharedDomainData, but you could also send it through a channel or anything else. The crate is generic over signalling mechanism via closure.

A command is any type that implements the Command trait. The generic parameter is a services type — config, API clients, or anything the command needs. Use () if you don't need any:

struct FetchUser(i32);

#[async_trait::async_trait]
impl Command<ApiClient> for FetchUser {
    type Output = User;
    type Error = AppError;

    async fn execute(self, client: ApiClient) -> Result<User, AppError> {
        client.get_user(self.0).await
    }
}

You then run the command and provide a closure for what to do with the result:

.exec(FetchUser(42), |shared, user| { /* do something with user */ })

FetchUser(42) runs asynchronously with access to the ApiClient. When it finishes, the closure receives the output and a mutable reference to your shared state. You can then update the shared state with the user information which can then be immediately displayed in a TUI. Composing multiple commands into chains, tracking their status, or running them in parallel all use the same pattern.

Setup

Define your app state. Create a SharedDomainData and spawn its write handle as a background task:

#[derive(Clone, Default)]
struct AppData {
    counter: i32,
}

let (domain, write_handle) =
    SharedDomainData::with_coalesce(AppData::default(), Duration::from_micros(500));
tokio::spawn(write_handle.run());

Read state instantly from anywhere — no locks:

let guard = domain.read();
println!("counter = {}", guard.counter);

Write state without blocking — closures are sent through a channel and batched:

domain.modify(|shared| shared.counter += 1);

Commands

Implement the Command trait to define async operations. Each command declares its own Output and Error types:

struct Add(i32);

#[async_trait::async_trait]
impl Command<()> for Add {
    type Output = i32;
    type Error = CmdError;

    async fn execute(self, _: ()) -> Result<i32, CmdError> {
        Ok(self.0)
    }
}

The second argument to Command is a services type. You can pass config, API clients, or any shared dependencies. Use () if you don't need any:

struct Greet(String);

#[async_trait::async_trait]
impl Command<AppServices> for Greet {
    type Output = String;
    type Error = CmdError;

    async fn execute(self, services: AppServices) -> Result<String, CmdError> {
        Ok(format!("{} {}", services.prefix, self.0))
    }
}

Execute a command with .bind(services, rt).exec(...):

domain.bind((), rt)
    .exec(Add(10), |shared, v: &i32| shared.counter += *v)
    .go_detach();

Command Chains

Chain multiple commands with .exec() for sequential execution:

domain.bind((), rt)
    .exec(Add(10), |shared, v: &i32| shared.total += *v)
    .exec(Add(20), |shared, v: &i32| shared.total += *v)
    .exec(Add(5),  |shared, v: &i32| shared.total += *v)
    .go();

Pipelines with .then()

Use .then() to pass the previous command's output into the next. Build multi-step pipelines where each step feeds the one after it:

// ResolvePath -> ReadFile -> ParseConfig
domain.bind((), rt)
    .exec(ResolvePath("config.toml".into()), |shared, path: &PathBuf| shared.config_path = path.clone())
    .then(
        |path: &PathBuf| ReadFile(path.clone()),
        |shared, contents: &String| shared.raw_config = contents.clone(),
    )
    .then(
        |contents: &String| ParseConfig(contents.clone()),
        |shared, config: &Config| shared.config = config.clone(),
    )
    .go();

Error handling

Chains short-circuit on the first error. Use .on_error() to capture it. The returned ControlFlow tells you whether the chain completed or was interrupted:

// A command that always fails
struct Fail;

#[async_trait::async_trait]
impl Command<()> for Fail {
    type Output = ();
    type Error = CmdError;

    async fn execute(self, _: ()) -> Result<(), CmdError> {
        Err(CmdError)
    }
}

let handle = domain.bind((), rt)
    .on_error(|err, shared| shared.error_log.push(format!("{err}")))
    .exec(Add(10), |shared, v: &i32| shared.total += *v)
    .exec_discard(Fail)          // returns Err, causes short-circuit
    .exec(Add(5), |shared, v: &i32| shared.total += *v)  // skipped
    .go();

let flow = handle.await.unwrap();
assert_eq!(flow, ControlFlow::Break);

Status Tracking

Use .tracked() to store a command's TaskStatus directly in domain state. The resolved value lives inside the TaskStatus, so you don't need a separate field to hold the result:

#[derive(Clone, Default)]
struct AppData {
    search_status: TaskStatus<Vec<String>>,
}

domain.bind((), rt)
    .exec(SearchFiles("query".into()), |_, _| {})
    .tracked(|shared, status| shared.search_status = status)
    .go();

Then in your render loop, a single domain.read() covers loading, results, and errors:

match &domain.read().search_status {
    TaskStatus::Idle => {},                      // not started
    TaskStatus::Pending => {},                   // show spinner
    TaskStatus::Resolved(results) => {},         // render results
    TaskStatus::Error(e) => {},                  // show error
    TaskStatus::Aborted => {},                   // show cancelled
}

Examples

Only some of the features are mentioned here. The examples/ directory contains detailed, runnable examples covering every feature.

License

LGPL-3.0-or-later